diff --git a/.env.example b/.env.example index 160438e..1bdd664 100644 --- a/.env.example +++ b/.env.example @@ -29,3 +29,9 @@ HOST=127.0.0.1 # Vite dev dashboard. Only set this if you serve the dashboard from a # different host than the API (e.g. http://my-server.local). # DASHBOARD_ORIGINS=http://my-server.local,http://192.168.1.50 + +# Optional request and backup ceilings. The public OpenAI-compatible JSON body +# limit defaults to 16mb. Streamed database export/import defaults to 4 GiB; +# legacy base64 JSON backups retain a 128 MiB binary compatibility ceiling. +# LLMHARBOR_API_BODY_LIMIT=16mb +# LLMHARBOR_MAX_BACKUP_BYTES=4294967296 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f048440..c800e30 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,28 +1,115 @@ name: CI +permissions: + contents: read + on: push: branches: [main] pull_request: branches: [main] +concurrency: + group: ci-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + jobs: + scripts: + name: Validate command-line scripts + runs-on: ubuntu-latest + timeout-minutes: 10 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Validate Bash scripts + run: | + bash -n bin/llmharbor install.sh install-macos.sh + shellcheck bin/llmharbor install.sh install-macos.sh + + - name: Parse PowerShell scripts + shell: pwsh + run: | + $ErrorActionPreference = 'Stop' + foreach ($file in @('bin/llmharbor.ps1', 'install.ps1')) { + [void][scriptblock]::Create((Get-Content -LiteralPath $file -Raw)) + } + + scripts-native: + name: Native CLI (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 10 + strategy: + fail-fast: false + matrix: + os: [macos-latest, windows-latest] + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + + - name: Check macOS Bash entry points + if: runner.os == 'macOS' + shell: bash + run: | + /bin/bash -n bin/llmharbor install.sh install-macos.sh + /bin/bash bin/llmharbor help >/dev/null + + - name: Check Windows PowerShell entry point + if: runner.os == 'Windows' + shell: powershell + run: | + $ErrorActionPreference = 'Stop' + foreach ($file in @('bin/llmharbor.ps1', 'install.ps1')) { + [void][scriptblock]::Create((Get-Content -LiteralPath $file -Raw)) + } + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File bin/llmharbor.ps1 help *> $null + if ($LASTEXITCODE -ne 0) { throw "llmharbor help exited with $LASTEXITCODE" } + # Windows PowerShell 5.1 promotes native stderr to NativeCommandError + # under Stop. This command is expected to write an error and fail, so + # capture its process exit code without weakening the other checks. + $ErrorActionPreference = 'SilentlyContinue' + & powershell.exe -NoProfile -ExecutionPolicy Bypass -File bin/llmharbor.ps1 definitely-not-a-command *> $null + $unknownCommandExit = $LASTEXITCODE + $ErrorActionPreference = 'Stop' + if ($unknownCommandExit -eq 0) { throw 'An unknown command must return a non-zero exit code' } + exit 0 # Do not propagate the intentionally failing child process code. + test: - name: Test & build + name: Node ${{ matrix.node-version }} runs-on: ubuntu-latest + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + node-version: ['22.12.0', '24'] steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - - uses: actions/setup-node@v4 + - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0 with: - node-version: '20' + node-version: ${{ matrix.node-version }} cache: 'npm' - name: Install dependencies - run: npm install + run: npm ci + + - name: Lint dashboard + run: npm run lint - name: Run tests run: npm test - name: Build workspaces run: npm run build + + - name: Validate static website script + if: matrix.node-version == '24' + run: node --check website/script.js + + - name: Audit production dependencies + if: matrix.node-version == '24' + run: npm audit --omit=dev --audit-level=high diff --git a/.github/workflows/pages.yml b/.github/workflows/pages.yml index 1c811c3..cd63fcd 100644 --- a/.github/workflows/pages.yml +++ b/.github/workflows/pages.yml @@ -23,18 +23,21 @@ jobs: name: github-pages url: ${{ steps.deployment.outputs.page_url }} runs-on: ubuntu-latest + timeout-minutes: 10 steps: - name: Checkout - uses: actions/checkout@v4 + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false - name: Setup Pages - uses: actions/configure-pages@v5 + uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - name: Upload static site - uses: actions/upload-pages-artifact@v3 + uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0 with: path: website - name: Deploy to GitHub Pages id: deployment - uses: actions/deploy-pages@v4 + uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0 diff --git a/README.md b/README.md index ebc8403..a1bae33 100644 --- a/README.md +++ b/README.md @@ -3,41 +3,30 @@ # LLMHarbor - **Drop one anchor. Route every model.** - - A self-hosted personal API platform for free-tier and local LLM endpoints. Add your provider keys once, mint separate local client keys for every app or agent, and let LLMHarbor route requests across the models that are healthy and allowed. + A self-hosted OpenAI-compatible LLM gateway and control plane for provider APIs, OAuth-backed accounts, local models, and custom endpoints.

CI MIT License - Node 20+ + Node 22 or 24 LTS OpenAI compatible

Quick start · - Screenshots · API · Providers · Bulk import · - Access policy · + Access policy · Terms of Use · Website · Contributing

-

- LLMHarbor project logo: an anchor-shaped routing mark -

- -

- LLMHarbor playground dashboard -

- ## What is LLMHarbor? -LLMHarbor is a local personal API platform for routing chat completions across many upstream LLM providers. It exposes the OpenAI API shape your apps already know, then handles the messy parts behind it: multiple client API keys with route/provider/model access policies, encrypted provider keys, browser OAuth accounts, fallback order, health checks, per-key traffic tracking, custom provider endpoints, model probes, streaming responses, tool calls, and request analytics. +LLMHarbor routes chat completions across upstream LLM providers while exposing the OpenAI API shape your clients already know. It manages multiple client API keys with route/provider/model access policies, encrypted provider credentials, OAuth-backed accounts, fallback order, health checks, per-key traffic tracking, custom provider endpoints, model probes, streaming responses, tool calls, and request analytics. Use it when you want one stable local endpoint for experiments, coding agents, small tools, and personal workflows without wiring every provider into every app. @@ -50,7 +39,7 @@ LLMHarbor local proxy | | chooses a healthy model under quota v -Google · Groq · Cerebras · Mistral · OpenRouter · Cloudflare · Ollama · Custom endpoints +Provider APIs · OAuth-backed accounts · local models · custom endpoints ``` ## Why it exists @@ -60,10 +49,9 @@ Free tiers are useful, but they are scattered. Each provider has its own key, mo LLMHarbor puts a harbor in front of that traffic. - One local OpenAI-compatible base URL. -- Multiple personal client API keys for apps, agents, laptops, and experiments. +- Multiple client API keys for apps, agents, laptops, and experiments. - Per-key route, provider, and model gates before any upstream quota is spent. -- One llmharbor key per app, agent, laptop, or experiment with isolated access policy. -- API-key providers and browser OAuth accounts behind it. +- API-key providers and OAuth-backed accounts behind it. - A fallback chain you can inspect and reorder. - A dashboard that shows what happened after each request. @@ -75,80 +63,33 @@ It is not meant to sell free tiers as production infrastructure. It is meant to |---|---| | OpenAI compatibility | `POST /v1/chat/completions` and `GET /v1/models` work with OpenAI-style SDKs and clients. Model IDs are exposed as `provider/model` so duplicate upstream IDs stay unambiguous. | | Auto routing | Use `model: "auto"` and let the router choose the highest-priority healthy model under quota. | -| Fallbacks | On 429, 5xx, timeout, or provider failure, LLMHarbor cools that key down and tries the next enabled route. | +| Fallbacks | Retryable failures skip the failed credential for that request and continue through eligible routes. Rate-limit and quota failures also place that provider/model/key combination on a temporary cooldown. | | Streaming | Server-Sent Events are supported for `stream: true`. | | Tool calls | OpenAI-style `tools`, `tool_choice`, assistant `tool_calls`, and tool follow-up messages round trip through the proxy. | | Client keys | Mint multiple OpenAI-compatible client keys, label them by app or device, and set per-key route/provider/model access policies. | -| Browser OAuth | Connect supported browser accounts such as OpenAI/ChatGPT and Antigravity through loopback OAuth with encrypted refresh storage and live model discovery. | -| Key storage | Provider keys and OAuth tokens are encrypted with AES-256-GCM before they are written to SQLite. | +| OAuth-backed accounts | Connect OpenAI/ChatGPT and Antigravity through PKCE loopback OAuth, or Freebuff through its browser device flow, with encrypted token storage and live model discovery. | +| Key storage | Provider keys and OAuth tokens are encrypted with AES-256-GCM and a fresh 96-bit nonce per write before they are stored in SQLite. Local client keys are stored as one-way digests. | | Rate tracking | RPM, RPD, TPM, and TPD counters are tracked for upstream providers, models, and routing health. | | Sticky sessions | Multi-turn conversations can stay on the same model for a short window to avoid mid-thread model jumps. | -| Custom providers | Add any OpenAI-compatible endpoint from the dashboard. Local vLLM, Ollama-compatible gateways, OpenCode Zen, and private gateways fit here. | +| Custom providers | Add HTTP(S) OpenAI-compatible endpoints that pass the destination safety checks. Local vLLM, Ollama-compatible gateways, OpenCode Zen, and private gateways fit here. | | Model probes | Test whether a model works before putting traffic on it. | | Analytics | Track request count, success rate, latency, token use, provider split, model split, and recent failures. | -## Screenshots - -### Playground - -Send a request through the router, inspect the routed provider, and see latency without leaving the dashboard. - -

- LLMHarbor playground page -

- -### Keys - -Store provider credentials, create personal client keys, check health, connect browser OAuth accounts, and manage custom OpenAI-compatible endpoints. - -

- LLMHarbor keys page -

- -### Models - -Register built-in and custom endpoint models, probe live credentials, and keep model context defaults with the provider. - -

- LLMHarbor models page -

- -### Fallback chain - -Reorder the route list, toggle models on or off, and choose presets for quality, speed, or remaining budget. - -

- LLMHarbor fallback chain page -

- -### Analytics - -See traffic, latency, tokens, estimated savings, model breakdowns, and provider errors. - -

- LLMHarbor analytics page -

- -### Settings / access policy - -Scope each `llmharbor-*` key by route, provider, and model catalog while every app keeps using the same local `/v1` base URL. - -

- LLMHarbor settings page showing local API access controls -

- ## Supported providers LLMHarbor ships with adapters and catalog entries for the common free-tier and OpenAI-compatible routes. Some providers require account setup or have stricter terms than others. | Provider | Typical models or routes | Notes | |---|---|---| +| OpenAI API keys | OpenAI chat models | OpenAI-compatible API adapter. | | Google API keys | Gemini Flash and Pro family | Native adapter with OpenAI shape translation. | | OpenAI / ChatGPT OAuth | Account-discovered GPT and Codex routes | Browser OAuth account flow with loopback callback and live inventory. | | Antigravity OAuth | Google Code Assist / Gemini routes | Browser OAuth account flow with Code Assist inventory and reconnect handling. | +| Freebuff browser accounts | Account-discovered Freebuff routes | Browser-account token flow with session management. | | Groq | Llama, GPT-OSS, Qwen | Fast OpenAI-compatible route. | | Cerebras | Qwen and Llama routes | Fast inference, quota-dependent. | | SambaNova | DeepSeek, Llama, Gemma | OpenAI-compatible route. | +| NVIDIA NIM | NVIDIA-hosted open models | Credit- and quota-dependent OpenAI-compatible route. | | Mistral | Mistral Large, Codestral, Devstral | OpenAI-compatible route. | | OpenRouter | Free and paid OpenRouter models | Works well as an extra model pool. | | GitHub Models | GPT-4.1, GPT-4o family | Useful for prototyping. | @@ -157,13 +98,16 @@ LLMHarbor ships with adapters and catalog entries for the common free-tier and O | HuggingFace Router | Provider-routed open models | OpenAI-compatible route. | | Zhipu / Z.ai | GLM family | Terms differ by entity and endpoint. | | Ollama Cloud | Cloud model access | Good for local-first workflows. | -| Custom OpenAI-compatible | vLLM, LiteLLM, OpenCode Zen, private gateways | Add from the Keys page, then register models on Models. | +| Kilo Gateway | Provider-routed models | OpenAI-compatible aggregator; availability is provider-dependent. | +| Pollinations | Provider-routed models | OpenAI-compatible endpoint; availability is provider-dependent. | +| LLM7 | Provider-routed models | OpenAI-compatible aggregator; availability is provider-dependent. | +| Custom OpenAI-compatible | vLLM, LiteLLM, OpenCode Zen, private gateways | Add from Providers & keys, then register models on Models. | ## Quick start ### Prerequisites -- Node.js 20+ +- Node.js `^22.12.0` or `^24.0.0` (supported LTS lines) - npm - A provider API key, or a local OpenAI-compatible endpoint to add later @@ -205,14 +149,13 @@ Override with `LLMHARBOR_HOME`, `LLMHARBOR_BIN_DIR`, or `LLMHARBOR_REPO` when ne ```bash git clone https://github.com/PLASMA-FR/LLMHarbor.git cd LLMHarbor -npm install +npm ci ``` Create an environment file: ```bash -cp .env.example .env -node -e 'console.log("ENCRYPTION_KEY=" + require("crypto").randomBytes(32).toString("hex"))' >> .env +node -e 'const fs=require("fs"),key=require("crypto").randomBytes(32).toString("hex"),source=fs.readFileSync(".env.example","utf8");fs.writeFileSync(".env",source.replace(/^ENCRYPTION_KEY=.*$/m,`ENCRYPTION_KEY=${key}`),{mode:0o600});fs.chmodSync(".env",0o600)' ``` Start the server and dashboard together: @@ -243,13 +186,13 @@ http://localhost:3001 Then: -1. Go to **Keys** and add provider keys or a custom endpoint. +1. Go to **Providers & keys** and add provider keys or a custom endpoint. - For one key, paste it into **Add a provider key**. - - For many keys, use **Bulk import provider keys** with a `.txt` file: one key per line. The provider id follows the visible list, so Google is `1`, Groq is `2`, and custom providers continue after built-ins. + - For many keys, use **Bulk import provider keys** with a `.txt` file: choose the provider target, then upload one key per line. The dashboard submits the provider's stable platform identifier. 2. Go to **Models** and probe the models you want to use. -3. Optional: go to **OAuth** and connect a supported browser account such as OpenAI/ChatGPT or Antigravity. -4. Go to **Fallback** and order the route list. -5. Create or copy a client API key from **Keys**. +3. Optional: go to **OAuth accounts** and connect OpenAI/ChatGPT, Antigravity, or Freebuff. +4. Go to **Routing** and order the route list. +5. Create a client API key from **Providers & keys** and save the secret shown once. 6. Open **Settings** and restrict that key to specific local routes, provider endpoints, or model rows. 7. Point your OpenAI-compatible client at `http://localhost:3001/v1`. @@ -276,7 +219,7 @@ llmharbor logs ### Tailscale dashboard + public API split -Use split mode when you want the dashboard/control plane reachable only on your Tailscale IP while the OpenAI-compatible API is reachable on the machine's public IP. The public listener only serves `/v1/*` plus `/api/ping`; dashboard pages and key-management routes are not mounted there. +Use split mode when you want the dashboard/control plane reachable only on your Tailscale IP while the OpenAI-compatible API is reachable on the machine's public IP. The public listener serves `/v1/*`, legacy `/e/:slug/v1/*` compatibility routes, and `/api/ping`; dashboard pages and mutating control-plane routes are not mounted there. ```bash # Detect your Tailscale IPv4 and write the split listener settings to .env. @@ -307,7 +250,7 @@ LLMHARBOR_PUBLIC_API_HOST=0.0.0.0 LLMHARBOR_PUBLIC_API_PORT=3001 ``` -Keep a firewall in front of the public port and use scoped `llmharbor-*` client keys for apps that call the public `/v1` API. +`--trusted-network` allows non-loopback clients that can reach the dashboard listener to use the control plane; it does not add dashboard login or another authentication layer. Bind that listener only to a private VPN interface. Keep a firewall in front of the public port and use scoped `llmharbor-*` client keys for apps that call the public `/v1` API. ## Using the API @@ -346,7 +289,7 @@ curl http://localhost:3001/v1/chat/completions \ }' ``` -`GET /v1/models` returns `auto` first, followed by routeable catalog entries such as `groq/llama-3.3-70b-versatile` or `openrouter/openai/gpt-oss-120b:free`. Send those exact provider-prefixed IDs when you want to pin a provider/model instead of auto-routing. +`GET /v1/models` returns only models that are enabled, allowed by the client key, and backed by an eligible configured credential. It includes `auto` when at least one such model exists. Transient cooldown or quota pressure does not make catalog rows disappear between SDK refreshes; inspect Routing for current availability. Send an exact provider-prefixed ID from this response when you want the router to try that provider/model first. ### Client key access policies @@ -377,9 +320,9 @@ curl -X PATCH http://127.0.0.1:3001/api/settings/api-keys/1/access-policy \ Custom local endpoint creation is intentionally retired: `POST /api/settings/local-endpoints` returns `410`. Keep using `/v1` and segment apps with key-specific policy instead. -### Browser OAuth accounts +### OAuth-backed accounts -The OAuth page connects supported browser accounts through loopback callbacks and encrypted token storage. Discovery refreshes provider-reported model inventory and usage windows so `/v1/models` only exposes models that are actually routeable. Antigravity uses Google Code Assist's native desktop client by default, so the Connect button is available on a fresh local install; set `LLMHARBOR_ANTIGRAVITY_OAUTH_CLIENT_SECRET` only if Google rotates that public client credential and you need to override it. +The OAuth page connects OpenAI/ChatGPT and Antigravity accounts through PKCE loopback callbacks and connects Freebuff through its browser device-code flow. Tokens are stored encrypted. Discovery refreshes provider-reported model inventory and usage windows so `/v1/models` only exposes models that are actually routeable. When you open the dashboard from another machine over Tailscale/VPN, the provider's fixed `localhost` redirect may end on that remote browser instead of the LLMHarbor host. The dashboard then offers a short-lived field for the complete returned callback URL; LLMHarbor validates the exact loopback route and consumes the original state and PKCE verifier once before exchanging the code. Antigravity uses Google Code Assist's native desktop client by default, so the Connect button is available on a fresh local install; set `LLMHARBOR_ANTIGRAVITY_OAUTH_CLIENT_SECRET` only if Google rotates that public client credential and you need to override it. Qwen OAuth was removed because the device-code path no longer provides a usable free approval flow and can require a paid Qwen account before approval. Use Qwen-family models through supported free-tier providers such as OpenRouter, Groq, or Cerebras when available. @@ -403,14 +346,14 @@ key-two key-three ``` -Open **Keys → Bulk import provider keys**, choose the provider id, upload the file, and import. Provider ids are based on the visible provider order: Google is `1`, Groq is `2`, and custom endpoints continue after the built-ins. Cloudflare lines should use the same stored shape as the single-key form: `account_id:api_token`. +Open **Providers & keys → Bulk import provider keys**, choose a target from the current provider list, upload the file, and import. The dashboard submits the provider's stable `platform` value returned by `GET /api/keys/providers`; legacy numeric list positions remain accepted only for older clients. Cloudflare lines use the same stored shape as the single-key form: `account_id:api_token`. The same flow is available through the local control-plane API: ```bash curl http://127.0.0.1:3001/api/keys/import \ -H "Content-Type: application/json" \ - -d '{"providerId":1,"contents":"key-one\nkey-two","labelPrefix":"Google batch"}' + -d '{"platform":"google","contents":"key-one\nkey-two","labelPrefix":"Google batch"}' ``` ### Streaming @@ -426,6 +369,8 @@ for chunk in stream: print(chunk.choices[0].delta.content or "", end="", flush=True) ``` +Streams use OpenAI-style `data: {...}` SSE frames followed by `data: [DONE]`. `stream_options: {"include_usage": true}` is accepted. A provider failure can fall back before the first substantive frame; after output starts, LLMHarbor emits a sanitized `stream_error` frame and terminates instead of replaying the request and duplicating partial output. + ### Tool calling ```python @@ -464,23 +409,26 @@ final = client.chat.completions.create( print(final.choices[0].message.content) ``` +Requests containing tool definitions, assistant tool calls, or tool-result messages only use routes that can preserve those semantics. In particular, the private ChatGPT OAuth Responses surface is excluded from tool-call requests instead of silently dropping tool data. + Every successful response includes routing headers when available: | Header | Meaning | |---|---| | `X-Routed-Via` | Provider and model that served the request. | -| `X-Fallback-Attempts` | Number of providers tried before success. | +| `X-Fallback-Attempts` | Number of failed route attempts before the successful route. | ## Dashboard map | Page | Use it for | |---|---| +| Overview | Check service health, route readiness, quota pressure, recent traffic, and failures. | | Playground | Send a test request and inspect the route result. | -| Keys | Manage local client keys, provider keys, key health, and custom providers. | -| OAuth | Connect browser accounts, refresh discovered models, inspect account limits, and handle reconnects. | +| Providers & keys | Manage local client keys, provider keys, key health, and custom providers. | +| OAuth accounts | Connect browser accounts, refresh discovered models, inspect account limits, and handle reconnects. | | Models | Register endpoint models and run probes. | -| Fallback | Reorder the chain and switch models on or off. | -| Analytics | Watch volume, latency, tokens, savings, errors, and model usage. | +| Routing | Reorder the fallback chain, inspect route eligibility, and switch models on or off. | +| Analytics | Watch final client outcomes, latency, tokens, provider/model distribution, and sanitized failures. Failed fallback attempts remain visible in the recent-error feed for diagnosis. | | Settings | Tune local API access policies for each `llmharbor-*` key. | ## How routing works @@ -491,19 +439,24 @@ flowchart LR B --> P{Route allowed by key policy?} P -->|no| Z[403 access denied] P -->|yes| C{Model requested?} - C -->|auto| D[Router picks highest priority healthy model] - C -->|specific model| E[Router finds matching enabled model] + C -->|auto or omitted| D[Order eligible routes by configured priority, rate-limit penalty, health, quota, and sticky preference] + C -->|provider/model| E[Try the requested eligible model first] D --> Q{Provider/model allowed?} E --> Q Q -->|no| Z Q -->|yes| F[Decrypt provider key in memory] F --> G[Call provider adapter] - G -->|success| H[Return OpenAI-shaped response] - G -->|429, timeout, 5xx| I[Cooldown key and try next fallback] - I --> D - H --> J[Write analytics, provider usage, and client-key usage] + G -->|success| H[Return normalized OpenAI-shaped response] + G -->|retryable before output| I[Skip failed route and try next eligible candidate] + G -->|error after stream starts| K[Send sanitized stream_error and stop] + I -->|rate limit or quota| L[Persist temporary provider/model/key cooldown] + I -->|other retryable failure| D + L --> D + H --> J[Reconcile usage and record the final client outcome] ``` +Routing exposes configured and effective priority, credential counts, cooldown state, and a concrete skip reason. Rate-limit penalties decay over time; other retryable failures only exclude the failed credential from the current request. + Main pieces: | Component | Path | Responsibility | @@ -525,15 +478,23 @@ Main pieces: LLMHarbor is local-first and single-user by design. -- Provider keys are encrypted at rest with AES-256-GCM. -- The encryption key comes from `ENCRYPTION_KEY` in `.env` for real use. -- The development fallback key is only for local experimentation. Do not use it with real provider credentials. -- Clients call LLMHarbor with one `llmharbor-...` token. +- Provider keys and OAuth tokens are encrypted at rest with AES-256-GCM and a fresh 96-bit nonce for every encryption. +- Installers generate a 64-character hexadecimal `ENCRYPTION_KEY` in `.env`. If no explicit key is configured for a file-backed database, LLMHarbor creates a mode-restricted `.key` sidecar instead. Startup fails closed when a configured key conflicts with the key that protects existing credentials. +- Local `llmharbor-*` client keys are stored as one-way SHA-256 digests and are shown only when created or regenerated. +- Clients call LLMHarbor with a `llmharbor-...` token. - Each local client token can have independent route, provider, and model policy. -- Upstream provider keys and OAuth refresh tokens never leave the server process. +- Provider keys and OAuth tokens are never returned in plaintext by listing or backup endpoints after storage. - The server binds to `127.0.0.1` by default. Set `HOST=0.0.0.0` only behind your own firewall, VPN, or authenticated reverse proxy. - The dashboard/control-plane API stays loopback-only by default even when the authenticated `/v1` proxy is remotely bound. Set `LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE=1` only behind your own network controls. -- Do not expose your LLMHarbor instance directly to the public internet. +- Browser requests are limited to the built-in Vite development origins plus exact, comma-separated origins in `DASHBOARD_ORIGINS`. CORS is not authentication and does not protect the control plane from non-browser clients. +- A same-host reverse proxy reaches LLMHarbor over loopback, so configure it to forward `/v1` only; never forward `/api` or dashboard routes to the public internet. +- Custom endpoint URLs accept HTTP(S) loopback/private-network targets for local models but reject embedded credentials, query strings, fragments, cloud-metadata/link-local destinations, redirects, and unsafe DNS resolutions. + +### Backups and restore + +Settings streams a consistent SQLite snapshot directly to disk, avoiding a large base64 document in browser memory. It contains encrypted provider/OAuth credentials, client-key digests, policies, routing state, usage, and analytics. Existing client-key secrets still authenticate after a restore, but their plaintext cannot be recovered from the backup. + +The export intentionally excludes `.env` and the generated `.key` sidecar. Preserve the matching `ENCRYPTION_KEY` or sidecar separately; ciphertext cannot be decrypted with a different key. The dashboard streams `.db` imports with an explicit restore-confirmation header and still accepts legacy `llmharbor.full-instance-backup.v1` JSON envelopes up to their 128 MiB binary compatibility limit. Use the streamed format for larger instances. It validates the optional checksum, SQLite integrity, startup schema compatibility, and credential-encryption key, then returns `202 Accepted`, stages the restore, and keeps the active database running. Restart LLMHarbor to activate it. Activation validates the staged files again, uses atomic file replacement, and restores the original files if activation fails; a timestamped pre-import database copy is also retained. ## What is not supported yet @@ -544,27 +505,18 @@ LLMHarbor focuses on OpenAI-compatible chat completions. These endpoint families - Embeddings: `/v1/embeddings` - Moderation: `/v1/moderations` - Legacy completions: `/v1/completions` +- Non-text image/audio message content (text content blocks are normalized; vision and audio inputs are not forwarded) - `n > 1` multi-completion requests - Multi-tenant auth, billing, orgs, or team management -## Verification status - -This rebrand was verified locally with automated scans and live app dogfooding: - -- `npm run build` for server TypeScript and the production Vite dashboard. -- Server Vitest coverage for routing, rate limits, OAuth discovery, proxy behavior, and client-key access policies. -- Client ESLint and dependency audit with zero reported vulnerabilities. -- Secret-pattern scan across tracked and untracked non-generated files. -- In-process API smoke against a mock OpenAI-compatible provider covering provider keys, custom endpoints, model registration, health checks, per-key access policies, `/v1/models`, non-streaming chat, streaming chat, fallback ordering, analytics, OAuth surfaces, and cleanup paths. -- Headless Chromium responsive sweep across Playground, Keys, OAuth, Models, Fallback, Analytics, and Settings at desktop, tablet, mobile, and 320px narrow widths. The sweep checks page titles, headings, root/body overflow, offscreen content outside scroll containers, and console/runtime errors. - ## Development ```bash -npm install +npm ci npm run dev # server on :3001, dashboard on :5173 npm test # server Vitest suite, plus client tests if present npm run build # TypeScript + Vite production build +npm run check # dashboard lint, tests, and both production builds ``` Useful workspace commands: @@ -587,11 +539,15 @@ CLI commands: ./bin/llmharbor update # git pull, rebuild, and restart if running ``` +Lifecycle commands serialize start/stop/update operations and validate saved process identity before signaling a PID. `start` and `restart` flags are one-shot unless you pass `--save`; a no-argument restart reuses newer runtime listener settings, while an edited `.env` or explicit environment values take precedence. Set `LLMHARBOR_STARTUP_TIMEOUT` to an integer from 1 to 300 seconds when startup needs longer than the default health-check window. + +`llmharbor update` accepts only a clean git checkout with a configured upstream and a fast-forward update. It fetches and validates the update before stopping a CLI-managed process, and refuses active systemd or untracked healthy listeners so it cannot stop the wrong service. Once the intentional stop has happened, an install or build failure leaves the service stopped and reports the failing command; fix the checkout and start it again rather than assuming an automatic rollback occurred. + Before opening a PR: ```bash -npm test -npm run build +npm run check +npm audit ``` ## Project structure @@ -600,7 +556,7 @@ npm run build LLMHarbor/ client/ React + Vite dashboard src/components/ Shared UI primitives and app shell pieces - src/pages/ Playground, Keys, OAuth, Models, Fallback, Analytics, Settings + src/pages/ Overview, playground, providers/keys, models, routing, OAuth, analytics, settings server/ Express API and provider routing src/db/ SQLite schema and model catalog src/providers/ Provider adapters @@ -609,8 +565,8 @@ LLMHarbor/ shared/ Shared TypeScript types bin/ LLMHarbor command line website/ Static GitHub Pages site - docs/ Logo, Open Graph assets, generated docs assets - repo-assets/ README screenshots + docs/ Project logos and Open Graph assets + repo-assets/ Historical dashboard screenshots install.sh Curl-friendly installer script ``` @@ -620,12 +576,13 @@ Common `.env` values: ```bash ENCRYPTION_KEY=replace-with-64-hex-characters +HOST=127.0.0.1 PORT=3001 -DEV_MODE=false -DATABASE_PATH=server/data/llmharbor.db +# DASHBOARD_ORIGINS=https://dashboard.example.internal +# LLMHARBOR_MAX_BACKUP_BYTES=4294967296 ``` -Provider keys are normally added in the dashboard. Keep `.env` and SQLite data out of commits. +Provider keys are normally added in the dashboard. SQLite data and the generated encryption-key sidecar live under `server/data/`. Keep `.env`, `server/data/`, exported backups, and encryption-key copies out of commits. ## Limitations and honest notes @@ -672,10 +629,6 @@ Use a free Google account for Antigravity OAuth instead of your primary, paid, w This is not legal advice. LLMHarbor contributors are not responsible for provider bans, quota changes, account suspensions, or service interruptions. -## Star history - -[![Star History Chart](https://api.star-history.com/chart?repos=PLASMA-FR/LLMHarbor&type=date&legend=top-left)](https://www.star-history.com/#PLASMA-FR/LLMHarbor&date) - ## License MIT. See [LICENSE](./LICENSE). diff --git a/bin/llmharbor b/bin/llmharbor index 161ba06..eb2de68 100755 --- a/bin/llmharbor +++ b/bin/llmharbor @@ -5,8 +5,11 @@ APP_NAME="LLMHarbor" DEFAULT_PORT="3001" DEFAULT_TAILSCALE_DASHBOARD_PORT="3002" DEFAULT_PUBLIC_API_PORT="3001" +MIN_NODE_MESSAGE="Node.js ^22.12.0 or ^24.0.0" START_FOREGROUND=false START_SAVE=false +STARTUP_TIMEOUT="15" +PENDING_START_PID="" declare -a START_OVERRIDES=() log() { @@ -35,9 +38,29 @@ resolve_script_path() { SCRIPT_DIR="$(resolve_script_path)" PROJECT_ROOT="${LLMHARBOR_HOME:-$(cd "$SCRIPT_DIR/.." >/dev/null 2>&1 && pwd)}" +if [[ -d "$PROJECT_ROOT" ]]; then + PROJECT_ROOT="$(cd "$PROJECT_ROOT" >/dev/null 2>&1 && pwd -P)" +fi STATE_DIR="$PROJECT_ROOT/.llmharbor" PID_FILE="$STATE_DIR/llmharbor.pid" +PID_START_FILE="$STATE_DIR/llmharbor.pid.start" +RUNTIME_CONFIG_FILE="$STATE_DIR/runtime.env" +LOCK_DIR="$STATE_DIR/lifecycle.lock" LOG_FILE="$STATE_DIR/llmharbor.log" +LOCK_HELD=false + +cleanup_cli() { + if [[ -n "$PENDING_START_PID" ]]; then + cleanup_pending_start "$PENDING_START_PID" + fi + if [[ "$LOCK_HELD" == "true" ]]; then + rm -f "$LOCK_DIR/pid" + rmdir "$LOCK_DIR" >/dev/null 2>&1 || true + LOCK_HELD=false + fi +} + +trap cleanup_cli EXIT usage() { cat <<'EOF' @@ -53,7 +76,7 @@ Commands: stop Stop the background production server restart Restart the background production server; accepts the same options as start status Show process and health-check status - logs Follow background server logs + logs Follow background server logs (use --no-follow for a snapshot) update Pull latest git changes, rebuild, and restart if already running tailscale Configure split mode: dashboard on Tailscale, public /v1 API on all interfaces open Open the dashboard in your browser @@ -79,6 +102,9 @@ Start options: --trusted-network Allow dashboard control-plane API on the private bind --save Persist the supplied start options to .env +Log options: + llmharbor logs [--follow|-f] [--lines|-n COUNT] + Environment: LLMHARBOR_HOME=/path/to/LLMHarbor Override project directory @@ -104,11 +130,73 @@ require_cmd() { command_exists "$1" || fail "Missing required command: $1" } +require_node() { + require_cmd node + local supported version + version="$(node -p 'process.versions.node')" || fail "Could not determine the Node.js version" + supported="$(node -e ' + const [major, minor] = process.versions.node.split(".").map(Number); + process.stdout.write(String((major === 22 && minor >= 12) || major === 24)); + ')" || fail "Could not validate the Node.js version" + [[ "$supported" == "true" ]] || fail "$MIN_NODE_MESSAGE is required; found Node.js $version" +} + +require_npm() { + require_cmd npm +} + +validate_startup_timeout() { + local timeout="${LLMHARBOR_STARTUP_TIMEOUT:-15}" + [[ "$timeout" =~ ^[0-9]+$ && "$timeout" -ge 1 && "$timeout" -le 300 ]] \ + || fail "LLMHARBOR_STARTUP_TIMEOUT must be between 1 and 300 seconds" + STARTUP_TIMEOUT="$timeout" +} + +ensure_no_args() { + local command="$1" + shift + [[ $# -eq 0 ]] || fail "$command does not accept arguments: $*" +} + +ensure_state_dir() { + mkdir -p "$STATE_DIR" + chmod 700 "$STATE_DIR" 2>/dev/null || true +} + +acquire_lifecycle_lock() { + [[ "$LOCK_HELD" == "false" ]] || return 0 + ensure_state_dir + + if ! mkdir "$LOCK_DIR" 2>/dev/null; then + local owner="" + [[ -f "$LOCK_DIR/pid" ]] && owner="$(<"$LOCK_DIR/pid")" + if [[ "$owner" =~ ^[0-9]+$ ]] && kill -0 "$owner" >/dev/null 2>&1; then + fail "Another LLMHarbor lifecycle command is running with PID $owner" + fi + rm -f "$LOCK_DIR/pid" + rmdir "$LOCK_DIR" >/dev/null 2>&1 || fail "Could not clear stale lifecycle lock: $LOCK_DIR" + mkdir "$LOCK_DIR" 2>/dev/null || fail "Could not acquire lifecycle lock: $LOCK_DIR" + fi + + printf '%s\n' "$$" > "$LOCK_DIR/pid" + LOCK_HELD=true +} + read_env_value() { local key="$1" local file="$PROJECT_ROOT/.env" [[ -f "$file" ]] || return 1 - grep -E "^${key}=" "$file" | tail -n 1 | cut -d= -f2- || return 1 + local line value + line="$(grep -E "^${key}=" "$file" | tail -n 1)" || return 1 + value="${line#*=}" + value="${value%$'\r'}" + if [[ "$value" == \"*\" && "$value" == *\" ]]; then + value="${value:1:${#value}-2}" + elif [[ "$value" == \'*\' && "$value" == *\' ]]; then + value="${value:1:${#value}-2}" + fi + [[ -n "$value" ]] || return 1 + printf '%s\n' "$value" } lookup_value() { @@ -133,6 +221,14 @@ validate_port() { (( port >= 1 && port <= 65535 )) || fail "$label must be between 1 and 65535: $port" } +validate_host() { + local label="$1" + local host="$2" + [[ -n "$host" ]] || fail "$label cannot be empty" + [[ ${#host} -le 253 ]] || fail "$label is too long" + [[ "$host" =~ ^[A-Za-z0-9._:%-]+$ ]] || fail "$label contains unsupported characters: $host" +} + require_option_value() { local opt="$1" local value="${2:-}" @@ -143,6 +239,9 @@ require_option_value() { set_start_override() { local key="$1" local value="$2" + case "$key" in + LLMHARBOR_DASHBOARD_HOST|LLMHARBOR_PUBLIC_API_HOST) validate_host "$key" "$value" ;; + esac export "$key=$value" START_OVERRIDES+=("$key=$value") } @@ -254,9 +353,6 @@ parse_start_options() { esac done - if [[ "$START_SAVE" == "true" ]]; then - persist_start_overrides - fi } first_value() { @@ -281,9 +377,11 @@ current_dashboard_port() { } current_dashboard_host() { - local legacy + local legacy host legacy="$(value_or_default HOST "127.0.0.1")" - first_value "$legacy" LLMHARBOR_DASHBOARD_HOST DASHBOARD_HOST + host="$(first_value "$legacy" LLMHARBOR_DASHBOARD_HOST DASHBOARD_HOST)" + validate_host LLMHARBOR_DASHBOARD_HOST "$host" + printf '%s\n' "$host" } current_public_api_port() { @@ -301,8 +399,17 @@ current_public_api_port() { printf '%s\n' "$port" } +has_public_api_port() { + lookup_value LLMHARBOR_PUBLIC_API_PORT >/dev/null 2>&1 \ + || lookup_value PUBLIC_API_PORT >/dev/null 2>&1 \ + || lookup_value API_PORT >/dev/null 2>&1 +} + current_public_api_host() { - first_value "0.0.0.0" LLMHARBOR_PUBLIC_API_HOST PUBLIC_API_HOST API_HOST + local host + host="$(first_value "0.0.0.0" LLMHARBOR_PUBLIC_API_HOST PUBLIC_API_HOST API_HOST)" + validate_host LLMHARBOR_PUBLIC_API_HOST "$host" + printf '%s\n' "$host" } url_host() { @@ -324,15 +431,23 @@ public_url_host() { } dashboard_url() { - printf 'http://%s:%s\n' "$(url_host "$(current_dashboard_host)")" "$(current_dashboard_port)" + local host port display_host + host="$(current_dashboard_host)" || return 1 + port="$(current_dashboard_port)" || return 1 + display_host="$(url_host "$host")" || return 1 + printf 'http://%s:%s\n' "$display_host" "$port" } api_base_url() { - local api_port - if api_port="$(current_public_api_port 2>/dev/null)"; then - printf 'http://%s:%s/v1\n' "$(public_url_host "$(current_public_api_host)")" "$api_port" + local api_port api_host display_host dashboard + if has_public_api_port; then + api_port="$(current_public_api_port)" || return 1 + api_host="$(current_public_api_host)" || return 1 + display_host="$(public_url_host "$api_host")" || return 1 + printf 'http://%s:%s/v1\n' "$display_host" "$api_port" else - printf '%s/v1\n' "$(dashboard_url)" + dashboard="$(dashboard_url)" || return 1 + printf '%s/v1\n' "$dashboard" fi } @@ -340,36 +455,28 @@ make_encryption_key() { node -e 'console.log(require("crypto").randomBytes(32).toString("hex"))' } -ensure_env() { - cd_project - require_cmd node - - if [[ ! -f .env ]]; then - cp .env.example .env +harden_data_directory() { + local data_dir="$PROJECT_ROOT/server/data" + if [[ ! -d "$data_dir" ]]; then + mkdir -p "$data_dir" || fail "Could not create database directory: $data_dir" + chmod 700 "$data_dir" 2>/dev/null \ + || log "Warning: could not set database directory permissions to 0700: $data_dir" fi - - if ! grep -Eq '^ENCRYPTION_KEY=[0-9a-fA-F]{64}$' .env; then - local key tmp - key="$(make_encryption_key)" - tmp="$(mktemp)" - if grep -q '^ENCRYPTION_KEY=' .env; then - sed "s/^ENCRYPTION_KEY=.*/ENCRYPTION_KEY=$key/" .env > "$tmp" - else - cat .env > "$tmp" - printf '\nENCRYPTION_KEY=%s\n' "$key" >> "$tmp" - fi - mv "$tmp" .env - log "Created a local ENCRYPTION_KEY in .env" + # Remove all group/other access without adding permissions that the owner + # deliberately removed on an existing installation. + if ! chmod go-rwx "$data_dir" 2>/dev/null; then + log "Warning: could not remove group/other access from database directory: $data_dir" fi } -upsert_env_value() { +write_env_value() { local key="$1" local value="$2" local file="$PROJECT_ROOT/.env" local tmp - ensure_env - tmp="$(mktemp)" + tmp="$(mktemp "$PROJECT_ROOT/.env.tmp.XXXXXX")" || fail "Could not create a temporary environment file" + chmod 600 "$tmp" 2>/dev/null || true + if grep -qE "^${key}=" "$file"; then while IFS= read -r line || [[ -n "$line" ]]; do case "$line" in @@ -381,129 +488,583 @@ upsert_env_value() { cat "$file" > "$tmp" printf '\n%s=%s\n' "$key" "$value" >> "$tmp" fi + mv "$tmp" "$file" + chmod 600 "$file" 2>/dev/null || true } -install_app() { +ensure_env() { cd_project - require_cmd node - require_cmd npm + require_node + harden_data_directory + + if [[ ! -f .env ]]; then + [[ -f .env.example ]] || fail "Missing environment template: $PROJECT_ROOT/.env.example" + cp .env.example .env + fi + chmod 600 .env 2>/dev/null || true + + local configured_key="" + configured_key="$(read_env_value ENCRYPTION_KEY 2>/dev/null || true)" + if [[ "$configured_key" =~ ^[0-9a-fA-F]{64}$ ]]; then + return 0 + fi + + if [[ -n "$configured_key" && "$configured_key" != "your-64-char-hex-key-here" ]]; then + fail "Invalid ENCRYPTION_KEY in $PROJECT_ROOT/.env; expected exactly 64 hexadecimal characters. Refusing to replace it because doing so could make stored credentials unreadable." + fi + + # Existing databases may already contain the generated key used to encrypt + # credentials. Never introduce a different environment key over that state. + if [[ -f server/data/llmharbor.db || -f server/data/freeapi.db ]]; then + log "Using the existing database-managed encryption key" + return 0 + fi + + local key + key="$(make_encryption_key)" + write_env_value ENCRYPTION_KEY "$key" + log "Created a local ENCRYPTION_KEY in .env" +} + +upsert_env_value() { + local key="$1" + local value="$2" ensure_env - npm install + write_env_value "$key" "$value" +} + +install_dependencies_and_build() { + if [[ -f package-lock.json ]]; then + npm ci + else + log "Warning: package-lock.json is missing; falling back to npm install" + npm install + fi npm run build - mkdir -p "$STATE_DIR" +} + +assert_managed_installation_idle() { + if is_running; then + fail "$APP_NAME is running under the CLI. Stop it before installing. Use 'llmharbor update' for a coordinated update." + fi + if systemd_manages_project; then + fail "$APP_NAME is managed by the active llmharbor.service. Stop the service before installing or updating files." + fi +} + +assert_installation_idle() { + assert_managed_installation_idle + if health_check "$(dashboard_health_url)"; then + fail "A healthy LLMHarbor listener exists at $(dashboard_url), but it is not managed by this CLI. Stop it before installing." + fi +} + +install_app() { + cd_project + acquire_lifecycle_lock + require_node + require_npm + assert_managed_installation_idle + validate_listener_config + assert_installation_idle + ensure_env + validate_listener_config + install_dependencies_and_build + ensure_state_dir log "$APP_NAME is installed. Run: llmharbor start" } -is_running() { +read_pid() { [[ -f "$PID_FILE" ]] || return 1 local pid pid="$(<"$PID_FILE")" - [[ -n "$pid" ]] && kill -0 "$pid" >/dev/null 2>&1 + [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 ]] || return 1 + printf '%s\n' "$pid" } -start_app() { +process_is_alive() { + local pid="$1" + kill -0 "$pid" >/dev/null 2>&1 || return 1 + local state + state="$(ps -p "$pid" -o stat= 2>/dev/null | sed 's/^[[:space:]]*//')" + [[ -n "$state" && "$state" != Z* ]] +} + +process_start_token() { + local pid="$1" stat rest + local -a stat_fields=() + if [[ -r "/proc/$pid/stat" ]]; then + stat="$(<"/proc/$pid/stat")" + rest="${stat##*) }" + # /proc//stat field 22 is the process start time in clock ticks. + # After removing pid/comm, it is field 20 of the remaining record. + read -r -a stat_fields <<< "$rest" + [[ ${#stat_fields[@]} -ge 20 && "${stat_fields[19]}" =~ ^[0-9]+$ ]] || return 1 + printf 'linux:%s\n' "${stat_fields[19]}" + return 0 + fi + + local started + started="$(LC_ALL=C TZ=UTC ps -p "$pid" -o lstart= 2>/dev/null \ + | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')" + [[ -n "$started" ]] || return 1 + printf 'ps:%s\n' "$started" +} + +process_working_directory() { + local pid="$1" + if [[ -d "/proc/$pid" ]]; then + readlink "/proc/$pid/cwd" 2>/dev/null + return + fi + if command_exists lsof; then + lsof -a -p "$pid" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -n 1 + fi +} + +legacy_process_matches() { + local pid="$1" + local args cwd canonical_cwd + args="$(ps -p "$pid" -o args= 2>/dev/null || true)" + [[ "$args" == *"server/dist/index.js"* ]] || return 1 + cwd="$(process_working_directory "$pid" || true)" + [[ -n "$cwd" && -d "$cwd" ]] || return 1 + canonical_cwd="$(cd "$cwd" >/dev/null 2>&1 && pwd -P)" || return 1 + [[ "$canonical_cwd" == "$PROJECT_ROOT" ]] +} + +managed_pid() { + local pid expected actual + pid="$(read_pid)" || return 1 + process_is_alive "$pid" || return 1 + + if [[ -f "$PID_START_FILE" ]]; then + expected="$(<"$PID_START_FILE")" + actual="$(process_start_token "$pid")" || return 1 + [[ -n "$actual" ]] || return 1 + if [[ -z "$expected" || "$actual" != "$expected" ]]; then + case "$expected" in + linux:*|ps:*) return 1 ;; + *) + # Migrate the pre-token-format record only after validating the + # process command and canonical working directory. This also + # recovers records written under a different timezone or locale. + legacy_process_matches "$pid" || return 1 + local token_tmp + token_tmp="$(mktemp "$STATE_DIR/.pid-start.tmp.XXXXXX")" || return 1 + printf '%s\n' "$actual" > "$token_tmp" + chmod 600 "$token_tmp" 2>/dev/null || true + mv "$token_tmp" "$PID_START_FILE" || return 1 + ;; + esac + fi + else + legacy_process_matches "$pid" || return 1 + fi + + printf '%s\n' "$pid" +} + +is_running() { + managed_pid >/dev/null 2>&1 +} + +clear_process_record() { + rm -f "$PID_FILE" "$PID_START_FILE" "$RUNTIME_CONFIG_FILE" +} + +clear_stale_process_record() { + [[ -e "$PID_FILE" || -e "$PID_START_FILE" ]] || return 0 + if ! is_running; then + clear_process_record + fi +} + +cleanup_pending_start() { + local pid="${1:-}" + [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 ]] || return 0 + PENDING_START_PID="" + + if process_is_alive "$pid"; then + kill -TERM "$pid" >/dev/null 2>&1 || true + for _ in {1..20}; do + process_is_alive "$pid" || break + sleep 0.1 + done + if process_is_alive "$pid"; then + kill -KILL "$pid" >/dev/null 2>&1 || true + fi + fi + clear_process_record +} + +write_process_record() { + local pid="$1" + local token pid_tmp token_tmp + token="$(process_start_token "$pid")" + if [[ -z "$token" ]]; then + kill -TERM "$pid" >/dev/null 2>&1 || true + fail "Could not record the server process identity" + fi + pid_tmp="$(mktemp "$STATE_DIR/.pid.tmp.XXXXXX")" + token_tmp="$(mktemp "$STATE_DIR/.pid-start.tmp.XXXXXX")" + printf '%s\n' "$token" > "$token_tmp" + printf '%s\n' "$pid" > "$pid_tmp" + chmod 600 "$pid_tmp" "$token_tmp" 2>/dev/null || true + mv "$token_tmp" "$PID_START_FILE" + mv "$pid_tmp" "$PID_FILE" +} + +write_runtime_config() { + local tmp dashboard_host dashboard_port public_port trusted remote + dashboard_host="$(current_dashboard_host)" || return 1 + dashboard_port="$(current_dashboard_port)" || return 1 + tmp="$(mktemp "$STATE_DIR/.runtime.tmp.XXXXXX")" + chmod 600 "$tmp" 2>/dev/null || true + { + printf 'LLMHARBOR_DASHBOARD_HOST=%s\n' "$dashboard_host" + printf 'LLMHARBOR_DASHBOARD_PORT=%s\n' "$dashboard_port" + if has_public_api_port; then + public_port="$(current_public_api_port)" || return 1 + local public_host + public_host="$(current_public_api_host)" || return 1 + printf 'LLMHARBOR_PUBLIC_API_HOST=%s\n' "$public_host" + printf 'LLMHARBOR_PUBLIC_API_PORT=%s\n' "$public_port" + fi + trusted="$(lookup_value LLMHARBOR_DASHBOARD_TRUSTED_NETWORK 2>/dev/null || true)" + remote="$(lookup_value LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE 2>/dev/null || true)" + [[ "$trusted" == "0" || "$trusted" == "1" ]] \ + && printf 'LLMHARBOR_DASHBOARD_TRUSTED_NETWORK=%s\n' "$trusted" + [[ "$remote" == "0" || "$remote" == "1" ]] \ + && printf 'LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE=%s\n' "$remote" + } > "$tmp" + mv "$tmp" "$RUNTIME_CONFIG_FILE" +} + +runtime_field_has_process_override() { + local runtime_key="$1" candidate + local -a candidates=() + case "$runtime_key" in + LLMHARBOR_DASHBOARD_HOST) + candidates=(LLMHARBOR_DASHBOARD_HOST DASHBOARD_HOST HOST) + ;; + LLMHARBOR_DASHBOARD_PORT) + candidates=(LLMHARBOR_DASHBOARD_PORT DASHBOARD_PORT PORT) + ;; + LLMHARBOR_PUBLIC_API_HOST) + candidates=(LLMHARBOR_PUBLIC_API_HOST PUBLIC_API_HOST API_HOST) + ;; + LLMHARBOR_PUBLIC_API_PORT) + candidates=(LLMHARBOR_PUBLIC_API_PORT PUBLIC_API_PORT API_PORT) + ;; + *) candidates=("$runtime_key") ;; + esac + + for candidate in "${candidates[@]}"; do + [[ -n "${!candidate+x}" ]] && return 0 + done + return 1 +} + +load_runtime_config() { + [[ -f "$RUNTIME_CONFIG_FILE" ]] || return 0 + local key value + while IFS='=' read -r key value || [[ -n "$key" ]]; do + case "$key" in + LLMHARBOR_DASHBOARD_HOST|LLMHARBOR_DASHBOARD_PORT|LLMHARBOR_PUBLIC_API_HOST|LLMHARBOR_PUBLIC_API_PORT|LLMHARBOR_DASHBOARD_TRUSTED_NETWORK|LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE) + if ! runtime_field_has_process_override "$key"; then + export "$key=$value" + fi + ;; + esac + done < "$RUNTIME_CONFIG_FILE" +} + +load_restart_runtime_config() { + [[ -f "$RUNTIME_CONFIG_FILE" && -f "$PROJECT_ROOT/.env" ]] || return 0 + # Runtime state is reusable only while the source configuration remains + # older. Equality is intentionally treated as changed/ambiguous. + [[ "$RUNTIME_CONFIG_FILE" -nt "$PROJECT_ROOT/.env" ]] || return 0 + load_runtime_config +} + +dashboard_health_url() { + local dashboard + dashboard="$(dashboard_url)" || return 1 + printf '%s/api/ping\n' "$dashboard" +} + +health_check() { + command_exists curl || return 2 + curl --noproxy '*' -fsS --connect-timeout 1 --max-time 2 "$1" >/dev/null 2>&1 +} + +systemd_manages_project() { + command_exists systemctl || return 1 + systemctl is-active --quiet llmharbor.service 2>/dev/null || return 1 + local working_directory + working_directory="$(systemctl show llmharbor.service -p WorkingDirectory --value 2>/dev/null || true)" + [[ -n "$working_directory" && -d "$working_directory" ]] || return 1 + working_directory="$(cd "$working_directory" >/dev/null 2>&1 && pwd -P)" || return 1 + [[ "$working_directory" == "$PROJECT_ROOT" ]] +} + +systemd_runs_current_process() { + local main_pid + main_pid="$(systemctl show llmharbor.service -p MainPID --value 2>/dev/null || true)" + [[ "$main_pid" =~ ^[0-9]+$ && "$main_pid" -eq "$$" ]] +} + +load_process_listener_environment() { + local pid="$1" + [[ -r "/proc/$pid/environ" ]] || return 0 + local assignment key value + while IFS= read -r -d '' assignment; do + key="${assignment%%=*}" + value="${assignment#*=}" + case "$key" in + HOST|PORT|DASHBOARD_HOST|DASHBOARD_PORT|PUBLIC_API_HOST|PUBLIC_API_PORT|API_HOST|API_PORT|LLMHARBOR_DASHBOARD_HOST|LLMHARBOR_DASHBOARD_PORT|LLMHARBOR_PUBLIC_API_HOST|LLMHARBOR_PUBLIC_API_PORT|LLMHARBOR_DASHBOARD_TRUSTED_NETWORK|LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE) + if ! runtime_field_has_process_override "$key"; then + export "$key=$value" + fi + ;; + esac + done < "/proc/$pid/environ" +} + +load_systemd_runtime_config() { + systemd_manages_project || return 1 + local pid + pid="$(systemctl show llmharbor.service -p MainPID --value 2>/dev/null || true)" + [[ "$pid" =~ ^[0-9]+$ && "$pid" -gt 1 ]] || return 0 + load_process_listener_environment "$pid" +} + +wait_for_startup() { + local pid="$1" + + if ! command_exists curl; then + sleep 1 + process_is_alive "$pid" + return + fi + + local deadline=$((SECONDS + STARTUP_TIMEOUT)) + while (( SECONDS < deadline )); do + process_is_alive "$pid" || return 1 + if health_check "$(dashboard_health_url)"; then + return 0 + fi + sleep 0.25 + done + return 1 +} + +ensure_production_build() { + if [[ -f server/dist/index.js && -f client/dist/index.html ]]; then + return 0 + fi + require_npm + log "Production build not found. Building first..." + npm run build +} + +validate_listener_config() { + local dashboard_host dashboard_port public_host public_port + dashboard_host="$(current_dashboard_host)" || return 1 + dashboard_port="$(current_dashboard_port)" || return 1 + if has_public_api_port; then + public_port="$(current_public_api_port)" || return 1 + public_host="$(current_public_api_host)" || return 1 + if [[ "$dashboard_port" == "$public_port" ]] \ + && [[ "$dashboard_host" == "$public_host" || "$dashboard_host" == "0.0.0.0" || "$dashboard_host" == "::" || "$public_host" == "0.0.0.0" || "$public_host" == "::" ]]; then + fail "Dashboard and public API listeners overlap on $dashboard_port; choose different ports or non-overlapping bind addresses" + fi + fi +} + +prepare_start() { + local reuse_runtime="$1" + shift cd_project parse_start_options "$@" - require_cmd node - require_cmd npm + acquire_lifecycle_lock + require_node ensure_env - mkdir -p "$STATE_DIR" + ensure_state_dir + + if [[ "$reuse_runtime" == "true" && $# -eq 0 ]] && is_running; then + load_restart_runtime_config + fi + + validate_listener_config + validate_startup_timeout + + if [[ "$START_SAVE" == "true" ]]; then + persist_start_overrides + fi +} + +start_prepared() { if [[ "$START_FOREGROUND" == "true" ]]; then if is_running; then - fail "$APP_NAME is already running with PID $(<"$PID_FILE"). Stop it first or omit --foreground for managed background mode." + fail "$APP_NAME is already running with PID $(managed_pid). Stop it first or omit --foreground for managed background mode." fi - - if [[ ! -f server/dist/index.js || ! -f client/dist/index.html ]]; then - log "Production build not found. Building first..." - npm run build + if systemd_manages_project && ! systemd_runs_current_process; then + fail "$APP_NAME is already managed by the active llmharbor.service systemd unit" fi - + ensure_production_build log "$APP_NAME starting in foreground" print_urls >&2 + cleanup_cli exec env NODE_ENV=production node server/dist/index.js fi if is_running; then - log "$APP_NAME is already running with PID $(<"$PID_FILE")" + load_runtime_config + log "$APP_NAME is already running with PID $(managed_pid)" return 0 fi + clear_stale_process_record - if [[ ! -f server/dist/index.js || ! -f client/dist/index.html ]]; then - log "Production build not found. Building first..." - npm run build + if systemd_manages_project; then + fail "$APP_NAME is already managed by llmharbor.service. Use sudo systemctl restart llmharbor.service instead." + fi + if health_check "$(dashboard_health_url)"; then + fail "A healthy LLMHarbor listener already exists at $(dashboard_url), but it is not managed by this CLI" fi + ensure_production_build + : > "$LOG_FILE" - NODE_ENV=production nohup node server/dist/index.js >> "$LOG_FILE" 2>&1 & + chmod 600 "$LOG_FILE" 2>/dev/null || true + NODE_ENV=production nohup node server/dist/index.js > "$LOG_FILE" 2>&1 & local pid=$! - printf '%s\n' "$pid" > "$PID_FILE" + PENDING_START_PID="$pid" + write_process_record "$pid" + write_runtime_config - sleep 1 - if ! kill -0 "$pid" >/dev/null 2>&1; then - rm -f "$PID_FILE" + if ! wait_for_startup "$pid"; then + cleanup_pending_start "$pid" log "Startup failed. Last logs:" tail -n 40 "$LOG_FILE" >&2 || true - exit 1 + fail "$APP_NAME did not become healthy within $STARTUP_TIMEOUT seconds" fi + PENDING_START_PID="" log "$APP_NAME started with PID $pid" print_urls >&2 } +start_app() { + prepare_start false "$@" + start_prepared +} + stop_app() { - if ! is_running; then - rm -f "$PID_FILE" + ensure_no_args stop "$@" + cd_project + acquire_lifecycle_lock + local pid + if ! pid="$(managed_pid)"; then + clear_stale_process_record + if systemd_manages_project; then + fail "$APP_NAME is managed by llmharbor.service. Stop it with: sudo systemctl stop llmharbor.service" + fi log "$APP_NAME is not running" return 0 fi - local pid - pid="$(<"$PID_FILE")" - kill "$pid" >/dev/null 2>&1 || true + kill -TERM "$pid" >/dev/null 2>&1 || true - for _ in {1..20}; do - if ! kill -0 "$pid" >/dev/null 2>&1; then - rm -f "$PID_FILE" + for _ in {1..50}; do + if ! process_is_alive "$pid" || ! is_running; then + clear_process_record log "$APP_NAME stopped" return 0 fi sleep 0.2 done - kill -9 "$pid" >/dev/null 2>&1 || true - rm -f "$PID_FILE" - log "$APP_NAME stopped forcefully" + if is_running; then + kill -KILL "$pid" >/dev/null 2>&1 || true + for _ in {1..10}; do + process_is_alive "$pid" || break + sleep 0.1 + done + fi + if process_is_alive "$pid" && is_running; then + fail "Could not stop $APP_NAME process $pid" + fi + clear_process_record + log "$APP_NAME stopped after it did not exit within 10 seconds" +} + +restart_app() { + prepare_start true "$@" + + local pid="" + if pid="$(managed_pid)"; then + # Complete all validation and any missing build before interrupting a + # healthy process. Invalid options and --help therefore never stop it. + ensure_production_build + stop_app + elif systemd_manages_project; then + fail "$APP_NAME is managed by llmharbor.service. Restart it with: sudo systemctl restart llmharbor.service" + elif health_check "$(dashboard_health_url)"; then + fail "A healthy LLMHarbor listener exists at $(dashboard_url), but it is not managed by this CLI" + fi + + start_prepared } status_app() { + ensure_no_args status "$@" cd_project - local dashboard health_url api_port api_health + local dashboard health_url api_port api_health pid="" failed=false managed=false + if pid="$(managed_pid)"; then + managed=true + load_runtime_config + else + clear_stale_process_record + load_systemd_runtime_config >/dev/null 2>&1 || true + fi dashboard="$(dashboard_url)" health_url="$dashboard/api/ping" - if is_running; then - printf 'Process: running, PID %s\n' "$(<"$PID_FILE")" + if [[ "$managed" == "true" ]]; then + printf 'Process: running, PID %s (CLI managed)\n' "$pid" + elif systemd_manages_project; then + printf 'Process: running (systemd managed)\n' else - printf 'Process: stopped\n' + printf 'Process: stopped\n' fi - if command_exists curl && curl -fsS --max-time 2 "$health_url" >/dev/null 2>&1; then + if health_check "$health_url"; then printf 'Dashboard: ok (%s)\n' "$dashboard" + elif ! command_exists curl; then + printf 'Dashboard: unchecked; curl is not installed (%s)\n' "$dashboard" + [[ "$managed" == "true" ]] || failed=true else printf 'Dashboard: unavailable (%s)\n' "$dashboard" + failed=true fi - if api_port="$(current_public_api_port 2>/dev/null)"; then + if has_public_api_port; then + api_port="$(current_public_api_port)" local api_host api_health api_display api_host="$(current_public_api_host)" api_health="http://$(url_host "$api_host"):$api_port/api/ping" api_display="http://$(public_url_host "$api_host"):$api_port/v1" - if command_exists curl && curl -fsS --max-time 2 "$api_health" >/dev/null 2>&1; then + if health_check "$api_health"; then printf 'Public API: ok (%s)\n' "$api_display" + elif ! command_exists curl; then + printf 'Public API: unchecked; curl is not installed (%s)\n' "$api_display" else printf 'Public API: unavailable (%s)\n' "$api_display" + failed=true fi else printf 'Public API: same listener (%s)\n' "$(api_base_url)" @@ -511,38 +1072,146 @@ status_app() { printf 'Home: %s\n' "$PROJECT_ROOT" printf 'Logs: %s\n' "$LOG_FILE" + [[ "$failed" == "false" ]] } show_logs() { - mkdir -p "$STATE_DIR" + local follow=true lines=120 value + while [[ $# -gt 0 ]]; do + case "$1" in + -f|--follow) + follow=true + shift + ;; + --no-follow) + follow=false + shift + ;; + -n|--lines) + value="$(require_option_value "$1" "${2:-}")" + lines="$value" + shift 2 + ;; + --lines=*) + lines="${1#*=}" + shift + ;; + -h|--help) + printf 'Usage: llmharbor logs [--follow|-f] [--lines|-n COUNT]\n' + return 0 + ;; + *) fail "Unknown logs option: $1" ;; + esac + done + [[ "$lines" =~ ^[0-9]+$ && "$lines" -le 100000 ]] || fail "Log line count must be between 0 and 100000: $lines" + + cd_project + if systemd_manages_project && command_exists journalctl; then + if [[ "$follow" == "true" ]]; then + exec journalctl -u llmharbor.service -n "$lines" -f + fi + exec journalctl -u llmharbor.service -n "$lines" --no-pager + fi + + ensure_state_dir touch "$LOG_FILE" - tail -n 120 -f "$LOG_FILE" + chmod 600 "$LOG_FILE" 2>/dev/null || true + if [[ "$follow" == "true" ]]; then + exec tail -n "$lines" -f "$LOG_FILE" + fi + tail -n "$lines" "$LOG_FILE" } update_app() { + ensure_no_args update "$@" cd_project + acquire_lifecycle_lock require_cmd git + require_node + require_npm + [[ -d .git ]] || fail "$PROJECT_ROOT is not a git checkout; update it through the installation method you used" + if ! git diff --quiet || ! git diff --cached --quiet; then + fail "Tracked files have local changes. Commit or stash them before updating." + fi + git rev-parse --verify '@{upstream}' >/dev/null 2>&1 \ + || fail "The current branch has no upstream. Configure one before running 'llmharbor update'." + + if systemd_manages_project; then + fail "$APP_NAME is managed by the active llmharbor.service. Stop the service before updating, then restart it after the update." + fi + local was_running=false if is_running; then was_running=true + ensure_env + load_restart_runtime_config + validate_listener_config + validate_startup_timeout + else + validate_listener_config + assert_installation_idle + ensure_env + validate_listener_config fi - git pull --ff-only - install_app + # Fetch while the service is still available. Validate fast-forwardability + # before stopping it so network and branch errors cannot cause downtime. + git fetch + if ! git merge-base --is-ancestor HEAD '@{upstream}' \ + && ! git merge-base --is-ancestor '@{upstream}' HEAD; then + fail "The current branch and its upstream have diverged; refusing a non-fast-forward update." + fi if [[ "$was_running" == "true" ]]; then stop_app + fi + + git merge --ff-only '@{upstream}' + install_app + + if [[ "$was_running" == "true" ]]; then start_app fi } configure_tailscale() { cd_project - require_cmd tailscale - local dashboard_port="${1:-$DEFAULT_TAILSCALE_DASHBOARD_PORT}" - local public_api_port="${2:-$DEFAULT_PUBLIC_API_PORT}" + local dashboard_port="$DEFAULT_TAILSCALE_DASHBOARD_PORT" + local public_api_port="$DEFAULT_PUBLIC_API_PORT" + local positional=0 value + while [[ $# -gt 0 ]]; do + case "$1" in + --dashboard-port) + dashboard_port="$(require_option_value "$1" "${2:-}")" + shift 2 + ;; + --dashboard-port=*) dashboard_port="${1#*=}"; shift ;; + --public-api-port|--api-port) + public_api_port="$(require_option_value "$1" "${2:-}")" + shift 2 + ;; + --public-api-port=*|--api-port=*) public_api_port="${1#*=}"; shift ;; + -h|--help) + printf 'Usage: llmharbor tailscale [DASHBOARD_PORT] [PUBLIC_API_PORT]\n' + printf ' llmharbor tailscale [--dashboard-port PORT] [--public-api-port PORT]\n' + return 0 + ;; + --*) fail "Unknown tailscale option: $1" ;; + *) + positional=$((positional + 1)) + if [[ "$positional" -eq 1 ]]; then dashboard_port="$1" + elif [[ "$positional" -eq 2 ]]; then public_api_port="$1" + else fail "tailscale accepts at most two positional ports" + fi + shift + ;; + esac + done validate_port LLMHARBOR_DASHBOARD_PORT "$dashboard_port" validate_port LLMHARBOR_PUBLIC_API_PORT "$public_api_port" + [[ "$dashboard_port" != "$public_api_port" ]] || fail "Dashboard and public API ports must be different in split mode" + acquire_lifecycle_lock + require_cmd tailscale local tail_ip tail_ip="$(tailscale ip -4 2>/dev/null | head -n 1 || true)" @@ -561,6 +1230,8 @@ configure_tailscale() { } open_app() { + ensure_no_args open "$@" + if is_running; then load_runtime_config; else load_systemd_runtime_config >/dev/null 2>&1 || true; fi local url url="$(dashboard_url)" @@ -577,10 +1248,15 @@ open_app() { } print_urls() { - printf 'Dashboard: %s\n' "$(dashboard_url)" - printf 'API base: %s\n' "$(api_base_url)" - printf 'Chat: %s/chat/completions\n' "$(api_base_url)" - if current_public_api_port >/dev/null 2>&1; then + ensure_no_args url "$@" + if is_running; then load_runtime_config; else load_systemd_runtime_config >/dev/null 2>&1 || true; fi + local dashboard api + dashboard="$(dashboard_url)" || return 1 + api="$(api_base_url)" || return 1 + printf 'Dashboard: %s\n' "$dashboard" + printf 'API base: %s\n' "$api" + printf 'Chat: %s/chat/completions\n' "$api" + if has_public_api_port; then printf 'Mode: split (dashboard private, /v1 public)\n' else printf 'Mode: single listener\n' @@ -588,9 +1264,10 @@ print_urls() { } doctor() { + ensure_no_args doctor "$@" cd_project local failed=false - for cmd in git node npm curl; do + for cmd in git npm curl; do if command_exists "$cmd"; then printf 'ok %s: %s\n' "$cmd" "$(command -v "$cmd")" else @@ -599,6 +1276,24 @@ doctor() { fi done + if command_exists node; then + local node_version node_supported + node_version="$(node -p 'process.versions.node' 2>/dev/null || true)" + node_supported="$(node -e ' + const [major, minor] = process.versions.node.split(".").map(Number); + process.stdout.write(String((major === 22 && minor >= 12) || major === 24)); + ' 2>/dev/null || true)" + if [[ "$node_supported" == "true" ]]; then + printf 'ok node: %s (%s)\n' "$(command -v node)" "$node_version" + else + printf 'miss node: unsupported %s; need %s\n' "${node_version:-unknown}" "$MIN_NODE_MESSAGE" + failed=true + fi + else + printf 'miss node\n' + failed=true + fi + if command_exists tailscale; then printf 'ok tailscale: %s\n' "$(command -v tailscale)" else @@ -606,15 +1301,39 @@ doctor() { fi if [[ -f .env ]]; then - printf 'ok .env\n' + local env_mode configured_key + env_mode="$(stat -c '%a' .env 2>/dev/null || stat -f '%Lp' .env 2>/dev/null || true)" + if [[ "$env_mode" == *00 ]]; then + printf 'ok .env (mode %s)\n' "$env_mode" + else + printf 'warn .env permissions are %s; run: chmod 600 %s/.env\n' "${env_mode:-unknown}" "$PROJECT_ROOT" + failed=true + fi + configured_key="$(read_env_value ENCRYPTION_KEY 2>/dev/null || true)" + if [[ -n "$configured_key" && "$configured_key" != "your-64-char-hex-key-here" && ! "$configured_key" =~ ^[0-9a-fA-F]{64}$ ]]; then + printf 'miss .env has an invalid ENCRYPTION_KEY\n' + failed=true + fi else printf 'miss .env (run: llmharbor install)\n' + failed=true fi if [[ -f server/dist/index.js && -f client/dist/index.html ]]; then printf 'ok production build\n' else printf 'miss production build (run: llmharbor install)\n' + failed=true + fi + + if systemd_manages_project; then + load_systemd_runtime_config >/dev/null 2>&1 || true + printf 'ok supervisor: systemd (llmharbor.service)\n' + elif is_running; then + load_runtime_config + printf 'ok supervisor: CLI-managed background process\n' + else + printf 'info supervisor: no running managed process\n' fi print_urls @@ -625,11 +1344,11 @@ cmd="${1:-help}" shift || true case "$cmd" in - install) install_app "$@" ;; - dev) cd_project; ensure_env; npm run dev "$@" ;; + install) ensure_no_args install "$@"; install_app ;; + dev) cd_project; require_node; require_npm; ensure_env; npm run dev -- "$@" ;; start) start_app "$@" ;; stop) stop_app "$@" ;; - restart) stop_app; start_app "$@" ;; + restart) restart_app "$@" ;; status) status_app "$@" ;; logs) show_logs "$@" ;; update) update_app "$@" ;; @@ -637,6 +1356,6 @@ case "$cmd" in open) open_app "$@" ;; url|urls) print_urls "$@" ;; doctor) doctor "$@" ;; - help|-h|--help) usage ;; - *) usage; fail "Unknown command: $cmd" ;; + help|-h|--help) ensure_no_args help "$@"; usage ;; + *) usage >&2; fail "Unknown command: $cmd" ;; esac diff --git a/bin/llmharbor.ps1 b/bin/llmharbor.ps1 index cef17f7..4a72c0c 100755 --- a/bin/llmharbor.ps1 +++ b/bin/llmharbor.ps1 @@ -1,171 +1,1018 @@ -# LLMHarbor Windows command line -param( - [Parameter(Position = 0)] - [string]$Command = "help" -) +# LLMHarbor Windows command line. Parse the automatic argument array instead +# of PowerShell parameters so Unix-style options such as --host pass through. +$RawArguments = @($args) +$Command = if ($RawArguments.Count -gt 0) { [string]$RawArguments[0] } else { "help" } +$CommandArgs = if ($RawArguments.Count -gt 1) { + [string[]]$RawArguments[1..($RawArguments.Count - 1)] +} else { + [string[]]@() +} $ErrorActionPreference = "Stop" +Set-StrictMode -Version 2.0 + $AppName = "LLMHarbor" $DefaultPort = "3001" +$DefaultTailscaleDashboardPort = "3002" +$DefaultPublicApiPort = "3001" +$MinimumNodeMessage = "Node.js ^22.12.0 or ^24.0.0" +$script:StartForeground = $false +$script:StartSave = $false +$script:StartOverrides = [ordered]@{} +$script:LockStream = $null +$script:StartupTimeout = 15 +$script:PendingStartProcess = $null + +function Fail([string]$Message) { + [Console]::Error.WriteLine("Error: $Message") + exit 1 +} + +function Log([string]$Message) { + [Console]::Error.WriteLine($Message) +} function Get-ProjectRoot { - if ($env:LLMHARBOR_HOME) { return (Resolve-Path $env:LLMHARBOR_HOME).Path } - return (Resolve-Path (Join-Path $PSScriptRoot ".." )).Path + $Candidate = if ($env:LLMHARBOR_HOME) { $env:LLMHARBOR_HOME } else { Join-Path $PSScriptRoot ".." } + if (-not (Test-Path -LiteralPath $Candidate -PathType Container)) { + Fail "Project directory not found: $Candidate" + } + return (Resolve-Path -LiteralPath $Candidate).Path } $ProjectRoot = Get-ProjectRoot $StateDir = Join-Path $ProjectRoot ".llmharbor" $PidFile = Join-Path $StateDir "llmharbor.pid" +$PidStartFile = Join-Path $StateDir "llmharbor.pid.start" +$RuntimeConfigFile = Join-Path $StateDir "runtime.json" +$LockFile = Join-Path $StateDir "lifecycle.lock" $LogFile = Join-Path $StateDir "llmharbor.log" +$ErrorLogFile = Join-Path $StateDir "llmharbor.error.log" +$EnvFile = Join-Path $ProjectRoot ".env" -function Fail($Message) { - Write-Error "Error: $Message" - exit 1 +function Require-Command([string]$Name) { + if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { + Fail "Missing required command: $Name" + } +} + +function Test-SupportedNode { + if (-not (Get-Command node -ErrorAction SilentlyContinue)) { return $false } + $Version = (& node -p "process.versions.node" 2>$null) + if ($LASTEXITCODE -ne 0 -or -not $Version) { return $false } + $Parts = $Version.Trim().Split(".") + if ($Parts.Count -lt 2) { return $false } + $Major = [int]$Parts[0] + $Minor = [int]$Parts[1] + return (($Major -eq 22 -and $Minor -ge 12) -or $Major -eq 24) } -function Require-Command($Name) { - if (-not (Get-Command $Name -ErrorAction SilentlyContinue)) { Fail "Missing required command: $Name" } +function Require-SupportedNode { + Require-Command "node" + if (-not (Test-SupportedNode)) { + $Version = (& node -p "process.versions.node" 2>$null) + if (-not $Version) { $Version = "unknown" } + Fail "$MinimumNodeMessage is required; found Node.js $Version" + } +} + +function Assert-StartupTimeout { + $TimeoutText = if ($env:LLMHARBOR_STARTUP_TIMEOUT) { $env:LLMHARBOR_STARTUP_TIMEOUT } else { "15" } + $Timeout = 0 + if (-not [int]::TryParse($TimeoutText, [ref]$Timeout) -or $Timeout -lt 1 -or $Timeout -gt 300) { + Fail "LLMHARBOR_STARTUP_TIMEOUT must be between 1 and 300 seconds" + } + $script:StartupTimeout = $Timeout } -function Get-Port { - if ($env:PORT) { $PortValue = $env:PORT } else { - $EnvFile = Join-Path $ProjectRoot ".env" - if (Test-Path $EnvFile) { - $Line = Get-Content $EnvFile | Where-Object { $_ -match '^PORT=' } | Select-Object -Last 1 - if ($Line) { $PortValue = ($Line -replace '^PORT=', '') } +function Invoke-Native([string]$File, [string[]]$Arguments) { + & $File @Arguments + if ($LASTEXITCODE -ne 0) { + Fail "$File exited with code $LASTEXITCODE" + } +} + +function Ensure-NoArgs([string]$Name, [string[]]$Arguments) { + $ActualArguments = @($Arguments | Where-Object { $null -ne $_ }) + if ($ActualArguments.Count -gt 0) { + Fail "$Name does not accept arguments: $($Arguments -join ' ')" + } +} + +function Ensure-StateDir { + New-Item -ItemType Directory -Force -Path $StateDir | Out-Null +} + +function Acquire-LifecycleLock { + if ($script:LockStream) { return } + Ensure-StateDir + + for ($Attempt = 0; $Attempt -lt 2; $Attempt++) { + try { + $script:LockStream = [System.IO.File]::Open( + $LockFile, + [System.IO.FileMode]::CreateNew, + [System.IO.FileAccess]::Write, + [System.IO.FileShare]::None + ) + $Bytes = [System.Text.Encoding]::ASCII.GetBytes("$PID`r`n") + $script:LockStream.Write($Bytes, 0, $Bytes.Length) + $script:LockStream.Flush() + return + } catch [System.IO.IOException] { + if ($Attempt -eq 0) { + # On Unix an open file may still be unlinked, so never remove a lock + # merely because CreateNew failed. Check the recorded owner first. + $OwnerId = 0 + try { + $OwnerText = (Get-Content -LiteralPath $LockFile -Raw -ErrorAction Stop).Trim() + if (-not [int]::TryParse($OwnerText, [ref]$OwnerId) -or $OwnerId -le 1) { + Fail "Lifecycle lock has no valid owner; remove it if no LLMHarbor command is running: $LockFile" + } + if (Get-Process -Id $OwnerId -ErrorAction SilentlyContinue) { + Fail "Another LLMHarbor lifecycle command is running with PID $OwnerId" + } + } catch { + # A live Windows owner opens the file without sharing, so an + # unreadable lock must also be treated as active rather than stale. + Fail "Another LLMHarbor lifecycle command is running (lock: $LockFile)" + } + try { + Remove-Item -LiteralPath $LockFile -Force -ErrorAction Stop + } catch { + Fail "Could not clear stale lifecycle lock: $LockFile" + } + } } - if (-not $PortValue) { $PortValue = $DefaultPort } } - if ($PortValue -notmatch '^[0-9]+$') { Fail "PORT must be numeric: $PortValue" } - $PortNumber = [int]$PortValue - if ($PortNumber -lt 1 -or $PortNumber -gt 65535) { Fail "PORT must be between 1 and 65535: $PortValue" } - return $PortValue + Fail "Another LLMHarbor lifecycle command is running (lock: $LockFile)" +} + +function Release-LifecycleLock { + if ($script:LockStream) { + $script:LockStream.Dispose() + $script:LockStream = $null + Remove-Item -LiteralPath $LockFile -Force -ErrorAction SilentlyContinue + } +} + +function Read-EnvValue([string]$Name) { + if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { return $null } + $Found = $null + foreach ($Line in (Get-Content -LiteralPath $EnvFile)) { + if ($Line.StartsWith("$Name=")) { $Found = $Line.Substring($Name.Length + 1) } + } + if ([string]::IsNullOrWhiteSpace($Found)) { return $null } + if ($Found.Length -ge 2) { + if (($Found.StartsWith('"') -and $Found.EndsWith('"')) -or ($Found.StartsWith("'") -and $Found.EndsWith("'"))) { + $Found = $Found.Substring(1, $Found.Length - 2) + } + } + if ([string]::IsNullOrWhiteSpace($Found)) { return $null } + return $Found +} + +function Get-ConfigValue([string]$Name) { + $ProcessValue = [Environment]::GetEnvironmentVariable($Name, "Process") + if (-not [string]::IsNullOrWhiteSpace($ProcessValue)) { return $ProcessValue } + return Read-EnvValue $Name +} + +function Get-FirstConfigValue([string]$Fallback, [string[]]$Names) { + foreach ($Name in $Names) { + $Value = Get-ConfigValue $Name + if (-not [string]::IsNullOrWhiteSpace($Value)) { return $Value } + } + return $Fallback +} + +function Assert-Port([string]$Label, [string]$Value) { + $Number = 0 + if (-not [int]::TryParse($Value, [ref]$Number)) { Fail "$Label must be numeric: $Value" } + if ($Number -lt 1 -or $Number -gt 65535) { Fail "$Label must be between 1 and 65535: $Value" } +} + +function Assert-Host([string]$Label, [string]$Value) { + if ([string]::IsNullOrWhiteSpace($Value)) { Fail "$Label cannot be empty" } + if ($Value.Length -gt 253 -or $Value -notmatch '^[A-Za-z0-9._:%-]+$') { + Fail "$Label contains unsupported characters: $Value" + } +} + +function Get-DashboardPort { + $Legacy = Get-FirstConfigValue $DefaultPort @("PORT") + $Value = Get-FirstConfigValue $Legacy @("LLMHARBOR_DASHBOARD_PORT", "DASHBOARD_PORT") + Assert-Port "LLMHARBOR_DASHBOARD_PORT" $Value + return $Value +} + +function Get-DashboardHost { + $Legacy = Get-FirstConfigValue "127.0.0.1" @("HOST") + $Value = Get-FirstConfigValue $Legacy @("LLMHARBOR_DASHBOARD_HOST", "DASHBOARD_HOST") + Assert-Host "LLMHARBOR_DASHBOARD_HOST" $Value + return $Value +} + +function Get-PublicApiPort { + $Value = Get-FirstConfigValue $null @("LLMHARBOR_PUBLIC_API_PORT", "PUBLIC_API_PORT", "API_PORT") + if ([string]::IsNullOrWhiteSpace($Value)) { return $null } + Assert-Port "LLMHARBOR_PUBLIC_API_PORT" $Value + return $Value +} + +function Get-PublicApiHost { + $Value = Get-FirstConfigValue "0.0.0.0" @("LLMHARBOR_PUBLIC_API_HOST", "PUBLIC_API_HOST", "API_HOST") + Assert-Host "LLMHARBOR_PUBLIC_API_HOST" $Value + return $Value +} + +function Get-UrlHost([string]$HostName) { + if ($HostName -eq "0.0.0.0") { return "localhost" } + if ($HostName -eq "::") { return "[::1]" } + if ($HostName.Contains(":") -and -not $HostName.StartsWith("[")) { return "[$HostName]" } + return $HostName +} + +function Get-PublicUrlHost([string]$HostName) { + if ($HostName -eq "0.0.0.0" -or $HostName -eq "::") { return "" } + return Get-UrlHost $HostName +} + +function Get-DashboardUrl { + return "http://$(Get-UrlHost (Get-DashboardHost)):$(Get-DashboardPort)" +} + +function Get-ApiBaseUrl { + $Port = Get-PublicApiPort + if ($Port) { return "http://$(Get-PublicUrlHost (Get-PublicApiHost)):$Port/v1" } + return "$(Get-DashboardUrl)/v1" +} + +function Assert-ListenerConfig { + $DashboardHost = Get-DashboardHost + $DashboardPort = Get-DashboardPort + $PublicPort = Get-PublicApiPort + if (-not $PublicPort) { return } + $PublicHost = Get-PublicApiHost + $Wildcard = @("0.0.0.0", "::") + if ($DashboardPort -eq $PublicPort -and ( + $DashboardHost -eq $PublicHost -or $Wildcard -contains $DashboardHost -or $Wildcard -contains $PublicHost + )) { + Fail "Dashboard and public API listeners overlap on $DashboardPort; choose different ports or non-overlapping bind addresses" + } +} + +function Write-Utf8NoBom([string]$Path, [string]$Content) { + $Encoding = New-Object System.Text.UTF8Encoding($false) + [System.IO.File]::WriteAllText($Path, $Content, $Encoding) +} + +function Write-EnvValue([string]$Name, [string]$Value) { + $Lines = @() + if (Test-Path -LiteralPath $EnvFile) { $Lines = @(Get-Content -LiteralPath $EnvFile) } + $Found = $false + $Output = foreach ($Line in $Lines) { + if ($Line.StartsWith("$Name=")) { + $Found = $true + "$Name=$Value" + } else { + $Line + } + } + if (-not $Found) { $Output = @($Output) + "" + "$Name=$Value" } + Write-Utf8NoBom $EnvFile ((@($Output) -join [Environment]::NewLine) + [Environment]::NewLine) } function New-EncryptionKey { - node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" + $Key = (& node -e "console.log(require('crypto').randomBytes(32).toString('hex'))") + if ($LASTEXITCODE -ne 0 -or $Key -notmatch '^[0-9a-fA-F]{64}$') { + Fail "Could not generate an encryption key" + } + return $Key.Trim() } function Ensure-Env { - Require-Command node - Push-Location $ProjectRoot + Require-SupportedNode + if (-not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { + $Template = Join-Path $ProjectRoot ".env.example" + if (-not (Test-Path -LiteralPath $Template -PathType Leaf)) { Fail "Missing environment template: $Template" } + Copy-Item -LiteralPath $Template -Destination $EnvFile + } + + $ConfiguredKey = Read-EnvValue "ENCRYPTION_KEY" + if ($ConfiguredKey -match '^[0-9a-fA-F]{64}$') { return } + if ($ConfiguredKey -and $ConfiguredKey -ne "your-64-char-hex-key-here") { + Fail "Invalid ENCRYPTION_KEY in $EnvFile; expected exactly 64 hexadecimal characters. Refusing to replace it because doing so could make stored credentials unreadable." + } + + $DatabaseExists = (Test-Path -LiteralPath (Join-Path $ProjectRoot "server\data\llmharbor.db")) -or + (Test-Path -LiteralPath (Join-Path $ProjectRoot "server\data\freeapi.db")) + if ($DatabaseExists) { + Log "Using the existing database-managed encryption key" + return + } + + Write-EnvValue "ENCRYPTION_KEY" (New-EncryptionKey) + Log "Created a local ENCRYPTION_KEY in .env" +} + +function Set-StartOverride([string]$Name, [string]$Value) { + if ($Name -eq "LLMHARBOR_DASHBOARD_HOST" -or $Name -eq "LLMHARBOR_PUBLIC_API_HOST") { + Assert-Host $Name $Value + } + [Environment]::SetEnvironmentVariable($Name, $Value, "Process") + $script:StartOverrides[$Name] = $Value +} + +function Set-StartPortOverride([string]$Name, [string]$Value) { + Assert-Port $Name $Value + Set-StartOverride $Name $Value +} + +function Get-RequiredOptionValue([string]$Option, [string[]]$Arguments, [int]$Index) { + if ($Index + 1 -ge $Arguments.Count -or [string]::IsNullOrWhiteSpace($Arguments[$Index + 1]) -or $Arguments[$Index + 1].StartsWith("--")) { + Fail "$Option requires a value" + } + return $Arguments[$Index + 1] +} + +function Parse-StartOptions([string[]]$Arguments) { + $Arguments = @($Arguments | Where-Object { $null -ne $_ }) + $script:StartForeground = $false + $script:StartSave = $false + $script:StartOverrides = [ordered]@{} + + for ($Index = 0; $Index -lt $Arguments.Count; $Index++) { + $Arg = $Arguments[$Index] + switch -Regex ($Arg) { + '^--(foreground|fg|no-daemon)$' { $script:StartForeground = $true; continue } + '^--(background|daemon)$' { $script:StartForeground = $false; continue } + '^--(host|dashboard-host)$' { + $Value = Get-RequiredOptionValue $Arg $Arguments $Index + Set-StartOverride "LLMHARBOR_DASHBOARD_HOST" $Value + $Index++; continue + } + '^--(host|dashboard-host)=(.+)$' { Set-StartOverride "LLMHARBOR_DASHBOARD_HOST" $Matches[2]; continue } + '^--(port|dashboard-port)$' { + $Value = Get-RequiredOptionValue $Arg $Arguments $Index + Set-StartPortOverride "LLMHARBOR_DASHBOARD_PORT" $Value + $Index++; continue + } + '^--(port|dashboard-port)=(.*)$' { Set-StartPortOverride "LLMHARBOR_DASHBOARD_PORT" $Matches[2]; continue } + '^--(public-api-host|api-host)$' { + $Value = Get-RequiredOptionValue $Arg $Arguments $Index + Set-StartOverride "LLMHARBOR_PUBLIC_API_HOST" $Value + $Index++; continue + } + '^--(public-api-host|api-host)=(.+)$' { Set-StartOverride "LLMHARBOR_PUBLIC_API_HOST" $Matches[2]; continue } + '^--(public-api-port|api-port)$' { + $Value = Get-RequiredOptionValue $Arg $Arguments $Index + Set-StartPortOverride "LLMHARBOR_PUBLIC_API_PORT" $Value + $Index++; continue + } + '^--(public-api-port|api-port)=(.*)$' { Set-StartPortOverride "LLMHARBOR_PUBLIC_API_PORT" $Matches[2]; continue } + '^--split$' { + if (-not (Get-ConfigValue "LLMHARBOR_PUBLIC_API_PORT")) { + Set-StartPortOverride "LLMHARBOR_PUBLIC_API_PORT" $DefaultPublicApiPort + } + continue + } + '^--(trusted-network|dashboard-trusted-network)$' { + Set-StartOverride "LLMHARBOR_DASHBOARD_TRUSTED_NETWORK" "1" + continue + } + '^--local-control-plane$' { + Set-StartOverride "LLMHARBOR_DASHBOARD_TRUSTED_NETWORK" "0" + Set-StartOverride "LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE" "0" + continue + } + '^--save$' { $script:StartSave = $true; continue } + '^(-h|--help)$' { Help; exit 0 } + '^--$' { + if ($Index + 1 -lt $Arguments.Count) { Fail "Unexpected start argument after --" } + continue + } + default { Fail "Unknown start option: $Arg" } + } + } +} + +function Save-StartOverrides { + foreach ($Entry in $script:StartOverrides.GetEnumerator()) { + Write-EnvValue $Entry.Key $Entry.Value + } +} + +function Read-Pid { + if (-not (Test-Path -LiteralPath $PidFile -PathType Leaf)) { return $null } + $Text = (Get-Content -LiteralPath $PidFile -Raw).Trim() + $Value = 0 + if (-not [int]::TryParse($Text, [ref]$Value) -or $Value -le 1) { return $null } + return $Value +} + +function Get-ProcessStartToken([System.Diagnostics.Process]$Process) { + $StatPath = "/proc/$($Process.Id)/stat" + if (Test-Path -LiteralPath $StatPath -PathType Leaf) { + try { + $Stat = [System.IO.File]::ReadAllText($StatPath) + $EndOfName = $Stat.LastIndexOf(") ", [StringComparison]::Ordinal) + if ($EndOfName -lt 0) { return $null } + $Fields = $Stat.Substring($EndOfName + 2).Split( + [char[]]@(' ', "`t"), + [StringSplitOptions]::RemoveEmptyEntries + ) + if ($Fields.Count -lt 20 -or $Fields[19] -notmatch '^[0-9]+$') { return $null } + return "linux:$($Fields[19])" + } catch { return $null } + } + try { return "windows:$($Process.StartTime.ToUniversalTime().Ticks)" } catch { return $null } +} + +function Get-LegacyManagedProcess([int]$ProcessId) { try { - if (-not (Test-Path ".env")) { Copy-Item ".env.example" ".env" } - $EnvText = Get-Content ".env" -Raw - if ($EnvText -notmatch '(?m)^ENCRYPTION_KEY=[0-9a-fA-F]{64}$') { - $Key = New-EncryptionKey - if ($EnvText -match '(?m)^ENCRYPTION_KEY=') { - $EnvText = $EnvText -replace '(?m)^ENCRYPTION_KEY=.*$', "ENCRYPTION_KEY=$Key" + $Info = Get-CimInstance Win32_Process -Filter "ProcessId = $ProcessId" -ErrorAction Stop + if (-not $Info -or -not $Info.CommandLine) { return $null } + + # Older Windows releases recorded the wrapping cmd.exe rather than its + # node child. Validate both the entrypoint and this installation's + # absolute log path before adopting the child process. + if ($Info.Name -match '^cmd\.exe$' -and + $Info.CommandLine -match 'server[/\\]dist[/\\]index\.js' -and + $Info.CommandLine.IndexOf($LogFile, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + $Children = @(Get-CimInstance Win32_Process -Filter "ParentProcessId = $ProcessId" -ErrorAction Stop) + foreach ($Child in $Children) { + if ($Child.Name -match '^node(\.exe)?$' -and $Child.CommandLine -match 'server[/\\]dist[/\\]index\.js') { + return Get-Process -Id ([int]$Child.ProcessId) -ErrorAction SilentlyContinue + } + } + return $null + } + + # Only accept a directly recorded legacy node process when its command + # line contains this canonical project root. This avoids adopting a + # different LLMHarbor checkout after PID reuse. + if ($Info.Name -match '^node(\.exe)?$' -and + $Info.CommandLine -match 'server[/\\]dist[/\\]index\.js' -and + $Info.CommandLine.IndexOf($ProjectRoot, [StringComparison]::OrdinalIgnoreCase) -ge 0) { + return Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + } + return $null + } catch { + return $null + } +} + +function Get-ManagedProcess { + $ProcessId = Read-Pid + if (-not $ProcessId) { return $null } + $Process = Get-Process -Id $ProcessId -ErrorAction SilentlyContinue + if (-not $Process) { return $null } + + if (Test-Path -LiteralPath $PidStartFile -PathType Leaf) { + $Expected = (Get-Content -LiteralPath $PidStartFile -Raw).Trim() + $Actual = Get-ProcessStartToken $Process + if (-not $Expected) { return $null } + if ($Actual -ne $Expected) { + # Migrate the unprefixed Windows tick token written by earlier releases. + if ($Actual -eq "windows:$Expected") { + Write-Utf8NoBom $PidStartFile "$Actual`r`n" } else { - $EnvText = $EnvText.TrimEnd() + "`r`nENCRYPTION_KEY=$Key`r`n" + return $null } - Set-Content -Path ".env" -Value $EnvText -NoNewline - Write-Host "Created a local ENCRYPTION_KEY in .env" } - } finally { Pop-Location } + } else { + $LegacyProcess = Get-LegacyManagedProcess $ProcessId + if (-not $LegacyProcess) { return $null } + Write-ProcessRecord $LegacyProcess + return $LegacyProcess + } + return $Process +} + +function Clear-ProcessRecord { + Remove-Item -LiteralPath $PidFile, $PidStartFile, $RuntimeConfigFile -Force -ErrorAction SilentlyContinue +} + +function Clear-StaleProcessRecord { + if (-not (Get-ManagedProcess)) { Clear-ProcessRecord } +} + +function Stop-PendingStart { + if (-not $script:PendingStartProcess) { return } + $Pending = $script:PendingStartProcess + $script:PendingStartProcess = $null + try { + $Pending.Refresh() + if (-not $Pending.HasExited) { + Stop-Process -Id $Pending.Id -Force -ErrorAction SilentlyContinue + try { Wait-Process -Id $Pending.Id -Timeout 5 -ErrorAction SilentlyContinue } catch { } + } + } catch { } + Clear-ProcessRecord } -function Test-Running { - if (-not (Test-Path $PidFile)) { return $false } - $PidValue = Get-Content $PidFile -Raw - $PidValue = $PidValue.Trim() - if (-not $PidValue) { return $false } - return [bool](Get-Process -Id ([int]$PidValue) -ErrorAction SilentlyContinue) +function Write-ProcessRecord([System.Diagnostics.Process]$Process) { + $Token = Get-ProcessStartToken $Process + if (-not $Token) { + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + Fail "Could not record the server process identity" + } + Ensure-StateDir + Write-Utf8NoBom $PidStartFile "$Token`r`n" + Write-Utf8NoBom $PidFile "$($Process.Id)`r`n" } -function Install-App { - Require-Command node - Require-Command npm - Ensure-Env +function Write-RuntimeConfig { + $Values = [ordered]@{ + LLMHARBOR_DASHBOARD_HOST = Get-DashboardHost + LLMHARBOR_DASHBOARD_PORT = Get-DashboardPort + } + $PublicPort = Get-PublicApiPort + if ($PublicPort) { + $Values["LLMHARBOR_PUBLIC_API_HOST"] = Get-PublicApiHost + $Values["LLMHARBOR_PUBLIC_API_PORT"] = $PublicPort + } + $TrustedNetwork = Get-ConfigValue "LLMHARBOR_DASHBOARD_TRUSTED_NETWORK" + if ($TrustedNetwork -eq "0" -or $TrustedNetwork -eq "1") { + $Values["LLMHARBOR_DASHBOARD_TRUSTED_NETWORK"] = $TrustedNetwork + } + $RemoteControlPlane = Get-ConfigValue "LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE" + if ($RemoteControlPlane -eq "0" -or $RemoteControlPlane -eq "1") { + $Values["LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE"] = $RemoteControlPlane + } + Write-Utf8NoBom $RuntimeConfigFile (($Values | ConvertTo-Json -Compress) + "`r`n") +} + +function Test-RuntimeFieldProcessOverride([string]$RuntimeKey) { + $Candidates = switch ($RuntimeKey) { + "LLMHARBOR_DASHBOARD_HOST" { @("LLMHARBOR_DASHBOARD_HOST", "DASHBOARD_HOST", "HOST"); break } + "LLMHARBOR_DASHBOARD_PORT" { @("LLMHARBOR_DASHBOARD_PORT", "DASHBOARD_PORT", "PORT"); break } + "LLMHARBOR_PUBLIC_API_HOST" { @("LLMHARBOR_PUBLIC_API_HOST", "PUBLIC_API_HOST", "API_HOST"); break } + "LLMHARBOR_PUBLIC_API_PORT" { @("LLMHARBOR_PUBLIC_API_PORT", "PUBLIC_API_PORT", "API_PORT"); break } + default { @($RuntimeKey) } + } + $ProcessEnvironment = [Environment]::GetEnvironmentVariables("Process") + foreach ($Candidate in $Candidates) { + if ($ProcessEnvironment.Contains($Candidate)) { return $true } + } + return $false +} + +function Load-RuntimeConfig { + if (-not (Test-Path -LiteralPath $RuntimeConfigFile -PathType Leaf)) { return } + try { $Values = Get-Content -LiteralPath $RuntimeConfigFile -Raw | ConvertFrom-Json } catch { return } + $Allowed = @( + "LLMHARBOR_DASHBOARD_HOST", "LLMHARBOR_DASHBOARD_PORT", + "LLMHARBOR_PUBLIC_API_HOST", "LLMHARBOR_PUBLIC_API_PORT", + "LLMHARBOR_DASHBOARD_TRUSTED_NETWORK", "LLMHARBOR_ALLOW_REMOTE_CONTROL_PLANE" + ) + foreach ($Property in $Values.PSObject.Properties) { + if ($Allowed -contains $Property.Name -and -not (Test-RuntimeFieldProcessOverride $Property.Name)) { + [Environment]::SetEnvironmentVariable($Property.Name, [string]$Property.Value, "Process") + } + } +} + +function Load-RestartRuntimeConfig { + if (-not (Test-Path -LiteralPath $RuntimeConfigFile -PathType Leaf) -or + -not (Test-Path -LiteralPath $EnvFile -PathType Leaf)) { return } + $RuntimeTime = (Get-Item -LiteralPath $RuntimeConfigFile -Force).LastWriteTimeUtc + $EnvironmentTime = (Get-Item -LiteralPath $EnvFile -Force).LastWriteTimeUtc + if ($RuntimeTime -le $EnvironmentTime) { return } + Load-RuntimeConfig +} + +function Test-Health([string]$Url) { + try { + $PreviousProgress = $ProgressPreference + $ProgressPreference = "SilentlyContinue" + Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 | Out-Null + return $true + } catch { + return $false + } finally { + $ProgressPreference = $PreviousProgress + } +} + +function Ensure-ProductionBuild { + $ServerBuild = Join-Path $ProjectRoot "server\dist\index.js" + $ClientBuild = Join-Path $ProjectRoot "client\dist\index.html" + if ((Test-Path -LiteralPath $ServerBuild -PathType Leaf) -and (Test-Path -LiteralPath $ClientBuild -PathType Leaf)) { return } + Require-Command "npm" + Log "Production build not found. Building first..." + Push-Location $ProjectRoot + try { Invoke-Native "npm" @("run", "build") } finally { Pop-Location } +} + +function Install-DependenciesAndBuild { Push-Location $ProjectRoot try { - npm install - npm run build - New-Item -ItemType Directory -Force -Path $StateDir | Out-Null - Write-Host "$AppName is installed. Run: llmharbor start" - } finally { Pop-Location } + if (Test-Path -LiteralPath (Join-Path $ProjectRoot "package-lock.json")) { + Invoke-Native "npm" @("ci") + } else { + Log "Warning: package-lock.json is missing; falling back to npm install" + Invoke-Native "npm" @("install") + } + Invoke-Native "npm" @("run", "build") + } finally { + Pop-Location + } } -function Start-App { - Require-Command node - Require-Command npm +function Assert-ManagedInstallationIdle { + $Running = Get-ManagedProcess + if ($Running) { + Fail "$AppName is running with PID $($Running.Id). Stop it before installing. Use 'llmharbor update' for a coordinated update." + } +} + +function Assert-InstallationIdle { + Assert-ManagedInstallationIdle + if (Test-Health "$(Get-DashboardUrl)/api/ping") { + Fail "A healthy LLMHarbor listener exists at $(Get-DashboardUrl), but it is not managed by this CLI. Stop it before installing." + } +} + +function Install-App { + Acquire-LifecycleLock + Require-SupportedNode + Require-Command "npm" + Assert-ManagedInstallationIdle + Assert-ListenerConfig + Assert-InstallationIdle Ensure-Env - New-Item -ItemType Directory -Force -Path $StateDir | Out-Null - if (Test-Running) { - Write-Host "$AppName is already running with PID $(Get-Content $PidFile)" + Assert-ListenerConfig + Install-DependenciesAndBuild + Ensure-StateDir + Write-Host "$AppName is installed. Run: llmharbor start" +} + +function Wait-ForStartup([System.Diagnostics.Process]$Process) { + $Deadline = [DateTime]::UtcNow.AddSeconds($script:StartupTimeout) + while ([DateTime]::UtcNow -lt $Deadline) { + $Process.Refresh() + if ($Process.HasExited) { return $false } + if (Test-Health "$(Get-DashboardUrl)/api/ping") { return $true } + Start-Sleep -Milliseconds 250 + } + return $false +} + +function Show-StartupFailureLogs { + Write-Host "Startup failed. Last logs:" + if (Test-Path -LiteralPath $LogFile) { Get-Content -LiteralPath $LogFile -Tail 30 } + if (Test-Path -LiteralPath $ErrorLogFile) { Get-Content -LiteralPath $ErrorLogFile -Tail 30 } +} + +function Prepare-Start([string[]]$Arguments, [bool]$ReuseRuntime) { + $ActualArguments = @($Arguments | Where-Object { $null -ne $_ }) + Parse-StartOptions $Arguments + Acquire-LifecycleLock + Require-SupportedNode + Ensure-Env + Ensure-StateDir + if ($ReuseRuntime -and $ActualArguments.Count -eq 0 -and (Get-ManagedProcess)) { + Load-RestartRuntimeConfig + } + Assert-ListenerConfig + Assert-StartupTimeout + if ($script:StartSave) { Save-StartOverrides } +} + +function Start-Prepared { + + $Existing = Get-ManagedProcess + if ($Existing) { + Load-RuntimeConfig + Write-Host "$AppName is already running with PID $($Existing.Id)" return } - if (-not (Test-Path (Join-Path $ProjectRoot "server/dist/index.js")) -or -not (Test-Path (Join-Path $ProjectRoot "client/dist/index.html"))) { + Clear-StaleProcessRecord + + if (Test-Health "$(Get-DashboardUrl)/api/ping") { + Fail "A healthy LLMHarbor listener already exists at $(Get-DashboardUrl), but it is not managed by this CLI" + } + Ensure-ProductionBuild + + if ($script:StartForeground) { + Log "$AppName starting in foreground" + Print-Urls @() + Release-LifecycleLock + $PreviousNodeEnv = $env:NODE_ENV + $env:NODE_ENV = "production" Push-Location $ProjectRoot - try { npm run build } finally { Pop-Location } - } - $Port = Get-Port - Set-Content -Path $LogFile -Value "" - $Args = "/c set PORT=$Port&& set NODE_ENV=production&& node server/dist/index.js >> `"$LogFile`" 2>&1" - $Process = Start-Process -FilePath "cmd.exe" -ArgumentList $Args -WorkingDirectory $ProjectRoot -WindowStyle Hidden -PassThru - Set-Content -Path $PidFile -Value $Process.Id - Start-Sleep -Seconds 1 - if (-not (Get-Process -Id $Process.Id -ErrorAction SilentlyContinue)) { - Remove-Item $PidFile -ErrorAction SilentlyContinue - Write-Host "Startup failed. Last logs:" - if (Test-Path $LogFile) { Get-Content $LogFile -Tail 40 } - exit 1 - } - Write-Host "$AppName started on http://localhost:$Port with PID $($Process.Id)" -} - -function Stop-App { - if (-not (Test-Running)) { - Remove-Item $PidFile -ErrorAction SilentlyContinue - Write-Host "$AppName is not running" - return + try { + & node "server/dist/index.js" + $ExitCode = $LASTEXITCODE + } finally { + Pop-Location + $env:NODE_ENV = $PreviousNodeEnv + } + exit $ExitCode } - $PidValue = [int](Get-Content $PidFile -Raw).Trim() - Stop-Process -Id $PidValue -Force -ErrorAction SilentlyContinue - Remove-Item $PidFile -ErrorAction SilentlyContinue - Write-Host "$AppName stopped" -} -function Status-App { - $Port = Get-Port - $Url = "http://localhost:$Port/api/ping" - if (Test-Running) { Write-Host "Process: running, PID $(Get-Content $PidFile)" } else { Write-Host "Process: stopped" } + $NodePath = (Get-Command node).Source + $PreviousNodeEnv = $env:NODE_ENV + $env:NODE_ENV = "production" try { - Invoke-WebRequest -Uri $Url -UseBasicParsing -TimeoutSec 2 | Out-Null - Write-Host "Health: ok ($Url)" + $StartParameters = @{ + FilePath = $NodePath + ArgumentList = @("server/dist/index.js") + WorkingDirectory = $ProjectRoot + RedirectStandardOutput = $LogFile + RedirectStandardError = $ErrorLogFile + PassThru = $true + } + if ([Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT) { + $StartParameters["WindowStyle"] = "Hidden" + } + $Process = Start-Process @StartParameters + } finally { + $env:NODE_ENV = $PreviousNodeEnv + } + + $script:PendingStartProcess = $Process + try { + Write-ProcessRecord $Process + Write-RuntimeConfig + if (-not (Wait-ForStartup $Process)) { + Stop-PendingStart + Show-StartupFailureLogs + Fail "$AppName did not become healthy within $($script:StartupTimeout) seconds" + } + $script:PendingStartProcess = $null } catch { - Write-Host "Health: unavailable ($Url)" + Stop-PendingStart + throw } - Write-Host "Home: $ProjectRoot" - Write-Host "Logs: $LogFile" + Write-Host "$AppName started with PID $($Process.Id)" + Print-Urls @() } -function Update-App { - Require-Command git - $WasRunning = Test-Running - Push-Location $ProjectRoot - try { git pull --ff-only } finally { Pop-Location } +function Start-App([string[]]$Arguments) { + Prepare-Start $Arguments $false + Start-Prepared +} + +function Stop-App([string[]]$Arguments) { + Ensure-NoArgs "stop" $Arguments + Acquire-LifecycleLock + $Process = Get-ManagedProcess + if (-not $Process) { + Clear-StaleProcessRecord + Log "$AppName is not running" + return + } + Stop-Process -Id $Process.Id -Force -ErrorAction SilentlyContinue + try { Wait-Process -Id $Process.Id -Timeout 10 -ErrorAction Stop } catch { } + if (Get-Process -Id $Process.Id -ErrorAction SilentlyContinue) { + Fail "Could not stop $AppName process $($Process.Id)" + } + Clear-ProcessRecord + Log "$AppName stopped" +} + +function Restart-App([string[]]$Arguments) { + $ActualArguments = @($Arguments | Where-Object { $null -ne $_ }) + Prepare-Start $ActualArguments $true + $Existing = Get-ManagedProcess + if ($Existing) { + # Finish validation and any missing build before interrupting a healthy + # service. `restart --help` and invalid options are therefore harmless. + Ensure-ProductionBuild + Stop-App @() + } elseif (Test-Health "$(Get-DashboardUrl)/api/ping") { + Fail "A healthy LLMHarbor listener exists at $(Get-DashboardUrl), but it is not managed by this CLI" + } + Start-Prepared +} + +function Status-App([string[]]$Arguments) { + Ensure-NoArgs "status" $Arguments + $Process = Get-ManagedProcess + if ($Process) { + Load-RuntimeConfig + Write-Host "Process: running, PID $($Process.Id) (CLI managed)" + } else { + Clear-StaleProcessRecord + Write-Host "Process: stopped" + } + + $Failed = $false + $Dashboard = Get-DashboardUrl + if (Test-Health "$Dashboard/api/ping") { + Write-Host "Dashboard: ok ($Dashboard)" + } else { + Write-Host "Dashboard: unavailable ($Dashboard)" + $Failed = $true + } + + $PublicPort = Get-PublicApiPort + if ($PublicPort) { + $PublicHost = Get-PublicApiHost + $HealthUrl = "http://$(Get-UrlHost $PublicHost):$PublicPort/api/ping" + $DisplayUrl = "http://$(Get-PublicUrlHost $PublicHost):$PublicPort/v1" + if (Test-Health $HealthUrl) { + Write-Host "Public API: ok ($DisplayUrl)" + } else { + Write-Host "Public API: unavailable ($DisplayUrl)" + $Failed = $true + } + } else { + Write-Host "Public API: same listener ($(Get-ApiBaseUrl))" + } + Write-Host "Home: $ProjectRoot" + Write-Host "Logs: $LogFile" + if ($Failed) { exit 1 } +} + +function Show-Logs([string[]]$Arguments) { + $Arguments = @($Arguments | Where-Object { $null -ne $_ }) + $Follow = $true + $LinesText = "120" + for ($Index = 0; $Index -lt $Arguments.Count; $Index++) { + $Arg = $Arguments[$Index] + switch -Regex ($Arg) { + '^(-f|--follow)$' { $Follow = $true; continue } + '^--no-follow$' { $Follow = $false; continue } + '^(-n|--lines)$' { + $LinesText = Get-RequiredOptionValue $Arg $Arguments $Index + $Index++; continue + } + '^--lines=(.*)$' { $LinesText = $Matches[1]; continue } + '^(-h|--help)$' { Write-Host "Usage: llmharbor logs [--follow|-f] [--lines|-n COUNT]"; return } + default { Fail "Unknown logs option: $Arg" } + } + } + $Lines = 0 + if (-not [int]::TryParse($LinesText, [ref]$Lines) -or $Lines -lt 0 -or $Lines -gt 100000) { + Fail "Log line count must be between 0 and 100000: $LinesText" + } + Ensure-StateDir + foreach ($Path in @($LogFile, $ErrorLogFile)) { + if (-not (Test-Path -LiteralPath $Path)) { New-Item -ItemType File -Path $Path | Out-Null } + } + $Paths = @($LogFile, $ErrorLogFile) + if ($Follow) { Get-Content -LiteralPath $Paths -Tail $Lines -Wait } + else { Get-Content -LiteralPath $Paths -Tail $Lines } +} + +function Update-App([string[]]$Arguments) { + Ensure-NoArgs "update" $Arguments + Acquire-LifecycleLock + Require-Command "git" + Require-SupportedNode + Require-Command "npm" + if (-not (Test-Path -LiteralPath (Join-Path $ProjectRoot ".git") -PathType Container)) { + Fail "$ProjectRoot is not a git checkout; update it through the installation method you used" + } + & git -C $ProjectRoot diff --quiet + if ($LASTEXITCODE -ne 0) { Fail "Tracked files have local changes. Commit or stash them before updating." } + & git -C $ProjectRoot diff --cached --quiet + if ($LASTEXITCODE -ne 0) { Fail "Tracked files have staged changes. Commit or stash them before updating." } + & git -C $ProjectRoot rev-parse --verify '@{upstream}' 2>$null | Out-Null + if ($LASTEXITCODE -ne 0) { Fail "The current branch has no upstream. Configure one before running 'llmharbor update'." } + + $WasRunning = [bool](Get-ManagedProcess) + if ($WasRunning) { + Ensure-Env + Load-RestartRuntimeConfig + Assert-ListenerConfig + Assert-StartupTimeout + } else { + Assert-ListenerConfig + Assert-InstallationIdle + Ensure-Env + Assert-ListenerConfig + } + + # Fetch and validate the update before stopping the service, so network and + # branch errors cannot cause avoidable downtime. + Invoke-Native "git" @("-C", $ProjectRoot, "fetch") + & git -C $ProjectRoot merge-base --is-ancestor HEAD '@{upstream}' + $HeadIsAncestor = $LASTEXITCODE -eq 0 + & git -C $ProjectRoot merge-base --is-ancestor '@{upstream}' HEAD + $UpstreamIsAncestor = $LASTEXITCODE -eq 0 + if (-not $HeadIsAncestor -and -not $UpstreamIsAncestor) { + Fail "The current branch and its upstream have diverged; refusing a non-fast-forward update." + } + + if ($WasRunning) { Stop-App @() } + Invoke-Native "git" @("-C", $ProjectRoot, "merge", "--ff-only", '@{upstream}') Install-App - if ($WasRunning) { Stop-App; Start-App } + if ($WasRunning) { Start-App @() } +} + +function Configure-Tailscale([string[]]$Arguments) { + $Arguments = @($Arguments | Where-Object { $null -ne $_ }) + $DashboardPort = $DefaultTailscaleDashboardPort + $PublicPort = $DefaultPublicApiPort + $Position = 0 + for ($Index = 0; $Index -lt $Arguments.Count; $Index++) { + $Arg = $Arguments[$Index] + switch -Regex ($Arg) { + '^--dashboard-port$' { $DashboardPort = Get-RequiredOptionValue $Arg $Arguments $Index; $Index++; continue } + '^--dashboard-port=(.*)$' { $DashboardPort = $Matches[1]; continue } + '^--(public-api-port|api-port)$' { $PublicPort = Get-RequiredOptionValue $Arg $Arguments $Index; $Index++; continue } + '^--(public-api-port|api-port)=(.*)$' { $PublicPort = $Matches[2]; continue } + '^(-h|--help)$' { + Write-Host "Usage: llmharbor tailscale [DASHBOARD_PORT] [PUBLIC_API_PORT]" + Write-Host " llmharbor tailscale [--dashboard-port PORT] [--public-api-port PORT]" + return + } + '^--' { Fail "Unknown tailscale option: $Arg" } + default { + $Position++ + if ($Position -eq 1) { $DashboardPort = $Arg } + elseif ($Position -eq 2) { $PublicPort = $Arg } + else { Fail "tailscale accepts at most two positional ports" } + } + } + } + Assert-Port "LLMHARBOR_DASHBOARD_PORT" $DashboardPort + Assert-Port "LLMHARBOR_PUBLIC_API_PORT" $PublicPort + if ($DashboardPort -eq $PublicPort) { Fail "Dashboard and public API ports must be different in split mode" } + Acquire-LifecycleLock + Require-Command "tailscale" + + $Output = (& tailscale ip -4 2>$null) + if ($LASTEXITCODE -ne 0) { Fail "Could not query Tailscale. Is Tailscale running?" } + $TailIps = @($Output | Where-Object { $_ }) + if ($TailIps.Count -eq 0) { Fail "Could not detect a Tailscale IPv4 address. Is Tailscale running?" } + $TailIp = ([string]$TailIps[0]).Trim() + Assert-Host "LLMHARBOR_DASHBOARD_HOST" $TailIp + + Ensure-Env + Write-EnvValue "LLMHARBOR_DASHBOARD_HOST" $TailIp + Write-EnvValue "LLMHARBOR_DASHBOARD_PORT" $DashboardPort + Write-EnvValue "LLMHARBOR_DASHBOARD_TRUSTED_NETWORK" "1" + Write-EnvValue "LLMHARBOR_PUBLIC_API_HOST" "0.0.0.0" + Write-EnvValue "LLMHARBOR_PUBLIC_API_PORT" $PublicPort + Log "Configured split mode in $EnvFile" + Log "Dashboard: http://${TailIp}:$DashboardPort" + Log "Public API: http://:$PublicPort/v1" + Log "Run: llmharbor restart" } -function Open-App { - $Port = Get-Port - Start-Process "http://localhost:$Port" +function Open-App([string[]]$Arguments) { + Ensure-NoArgs "open" $Arguments + if (Get-ManagedProcess) { Load-RuntimeConfig } + Start-Process (Get-DashboardUrl) } -function Print-Urls { - $Port = Get-Port - Write-Host "Dashboard: http://localhost:$Port" - Write-Host "API base: http://localhost:$Port/v1" - Write-Host "Chat: http://localhost:$Port/v1/chat/completions" +function Print-Urls([string[]]$Arguments) { + Ensure-NoArgs "url" $Arguments + if (Get-ManagedProcess) { Load-RuntimeConfig } + $Dashboard = Get-DashboardUrl + $Api = Get-ApiBaseUrl + Write-Host "Dashboard: $Dashboard" + Write-Host "API base: $Api" + Write-Host "Chat: $Api/chat/completions" + if (Get-PublicApiPort) { Write-Host "Mode: split (dashboard private, /v1 public)" } + else { Write-Host "Mode: single listener" } } -function Doctor { - foreach ($Cmd in @("git", "node", "npm")) { - if (Get-Command $Cmd -ErrorAction SilentlyContinue) { Write-Host "ok $Cmd" } else { Write-Host "miss $Cmd" } +function Doctor([string[]]$Arguments) { + Ensure-NoArgs "doctor" $Arguments + $Failed = $false + foreach ($Name in @("git", "npm")) { + $Found = Get-Command $Name -ErrorAction SilentlyContinue + if ($Found) { Write-Host "ok ${Name}: $($Found.Source)" } + else { Write-Host "miss $Name"; $Failed = $true } } - Write-Host "Home $ProjectRoot" + $Node = Get-Command node -ErrorAction SilentlyContinue + if ($Node -and (Test-SupportedNode)) { + $Version = (& node -p "process.versions.node") + Write-Host "ok node: $($Node.Source) ($Version)" + } else { + Write-Host "miss node: need $MinimumNodeMessage" + $Failed = $true + } + if (Test-Path -LiteralPath $EnvFile -PathType Leaf) { + $ConfiguredKey = Read-EnvValue "ENCRYPTION_KEY" + if (-not $ConfiguredKey -or $ConfiguredKey -eq "your-64-char-hex-key-here" -or $ConfiguredKey -match '^[0-9a-fA-F]{64}$') { + Write-Host "ok .env" + } else { + Write-Host "miss .env has an invalid ENCRYPTION_KEY" + $Failed = $true + } + } else { + Write-Host "miss .env (run: llmharbor install)" + $Failed = $true + } + if ((Test-Path -LiteralPath (Join-Path $ProjectRoot "server\dist\index.js")) -and + (Test-Path -LiteralPath (Join-Path $ProjectRoot "client\dist\index.html"))) { + Write-Host "ok production build" + } else { + Write-Host "miss production build (run: llmharbor install)" + $Failed = $true + } + if (Get-ManagedProcess) { Write-Host "ok supervisor: CLI-managed background process" } + else { Write-Host "info supervisor: no running managed process" } + Print-Urls @() + if ($Failed) { exit 1 } } function Help { @@ -173,35 +1020,72 @@ function Help { LLMHarbor command line Usage: - llmharbor + llmharbor [options] Commands: - install Install npm dependencies, create .env, and build production assets - dev Run the API and dashboard in development mode - start Start the production server in the background - stop Stop the background production server - restart Restart the background production server - status Show process and health-check status - logs Print recent background server logs - update Pull latest git changes, rebuild, and restart if already running - open Open the dashboard in your browser - url Print dashboard and OpenAI-compatible API URLs - doctor Check local prerequisites - help Show this help + install Install npm dependencies, create .env, and build production assets + dev Run the API and dashboard in development mode + start Start production in the background, or foreground with --foreground + stop Stop the CLI-managed background server + restart Restart the background server; accepts the same options as start + status Show process and health-check status + logs Follow server logs (use --no-follow for a snapshot) + update Pull latest git changes, rebuild, and restart if already running + tailscale Configure dashboard-private/public-API split mode + open Open the dashboard in your browser + url Print dashboard and OpenAI-compatible API URLs + doctor Check local prerequisites + help Show this help + +Start options: + --foreground, --fg, --no-daemon + --host HOST, --dashboard-host HOST + --port PORT, --dashboard-port PORT + --public-api-host HOST, --api-host HOST + --public-api-port PORT, --api-port PORT + --split + --trusted-network + --save + +Log options: + llmharbor logs [--follow|-f] [--lines|-n COUNT] + +Environment: + LLMHARBOR_HOME=C:\path\to\LLMHarbor "@ } -switch ($Command.ToLowerInvariant()) { - "install" { Install-App } - "dev" { Ensure-Env; Push-Location $ProjectRoot; try { npm run dev } finally { Pop-Location } } - "start" { Start-App } - "stop" { Stop-App } - "restart" { Stop-App; Start-App } - "status" { Status-App } - "logs" { if (Test-Path $LogFile) { Get-Content $LogFile -Tail 120 -Wait } else { Write-Host "No log file yet: $LogFile" } } - "update" { Update-App } - "open" { Open-App } - "url" { Print-Urls } - "doctor" { Doctor } - default { Help } +try { + switch ($Command.ToLowerInvariant()) { + "install" { Ensure-NoArgs "install" $CommandArgs; Install-App } + "dev" { + Require-SupportedNode + Require-Command "npm" + Ensure-Env + Push-Location $ProjectRoot + try { + & npm run dev -- @CommandArgs + if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE } + } finally { Pop-Location } + } + "start" { Start-App $CommandArgs } + "stop" { Stop-App $CommandArgs } + "restart" { Restart-App $CommandArgs } + "status" { Status-App $CommandArgs } + "logs" { Show-Logs $CommandArgs } + "update" { Update-App $CommandArgs } + "tailscale" { Configure-Tailscale $CommandArgs } + "configure-tailscale" { Configure-Tailscale $CommandArgs } + "open" { Open-App $CommandArgs } + "url" { Print-Urls $CommandArgs } + "urls" { Print-Urls $CommandArgs } + "doctor" { Doctor $CommandArgs } + "help" { Ensure-NoArgs "help" $CommandArgs; Help } + "-h" { Ensure-NoArgs "help" $CommandArgs; Help } + "--help" { Ensure-NoArgs "help" $CommandArgs; Help } + default { Help; Fail "Unknown command: $Command" } + } +} finally { + Stop-PendingStart + Release-LifecycleLock } diff --git a/client/README.md b/client/README.md index 7dbf7eb..9f01d7b 100644 --- a/client/README.md +++ b/client/README.md @@ -1,73 +1,15 @@ -# React + TypeScript + Vite +# LLMHarbor dashboard -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. +The React/Vite control plane for LLMHarbor. It is built into `client/dist` and served by the production Express process; the public split listener never mounts these routes. -Currently, two official plugins are available: +Use Node.js `^22.12.0` or `^24.0.0`. From the repository root: -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) +```bash +npm run dev -w client +npm run lint -w client +npm run build -w client ``` -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: +The development server proxies `/api` and `/v1` to the dashboard listener configured by the root `.env`. It resolves `LLMHARBOR_DASHBOARD_HOST`/`LLMHARBOR_DASHBOARD_PORT` first, then `DASHBOARD_HOST`/`DASHBOARD_PORT`, then `HOST`/`PORT`. The default target is `http://127.0.0.1:3001`. -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` +Keep control-plane requests in `src/lib/api.ts`, reuse the focused UI primitives under `src/components/ui`, and preserve keyboard navigation, explicit labels, loading/error/empty states, and narrow-window table scrolling when changing pages. diff --git a/client/index.html b/client/index.html index 334c42b..002a35a 100644 --- a/client/index.html +++ b/client/index.html @@ -2,13 +2,14 @@ - - - + + + - + - LLMHarbor · Drop one anchor. Route every model. + LLMHarbor Dashboard +
diff --git a/client/package.json b/client/package.json index 9c2c594..850e542 100644 --- a/client/package.json +++ b/client/package.json @@ -7,13 +7,11 @@ "dev": "vite", "build": "tsc -b && vite build", "lint": "eslint .", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { "@base-ui/react": "^1.3.0", - "@dnd-kit/core": "^6.3.1", - "@dnd-kit/sortable": "^10.0.0", - "@dnd-kit/utilities": "^3.2.2", "@fontsource-variable/geist": "^5.2.8", "@fontsource-variable/geist-mono": "^5.2.7", "@tailwindcss/vite": "^4.2.2", @@ -25,7 +23,6 @@ "react-dom": "^19.2.4", "react-router-dom": "^7.14.0", "recharts": "^3.8.1", - "shadcn": "^4.2.0", "tailwind-merge": "^3.5.0", "tailwindcss": "^4.2.2", "tw-animate-css": "^1.4.0" @@ -42,6 +39,7 @@ "globals": "^17.4.0", "typescript": "~6.0.2", "typescript-eslint": "^8.58.0", - "vite": "^8.0.4" + "vite": "^8.0.4", + "vitest": "^4.1.8" } } diff --git a/client/public/icons.svg b/client/public/icons.svg deleted file mode 100644 index e952219..0000000 --- a/client/public/icons.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/client/public/theme.js b/client/public/theme.js new file mode 100644 index 0000000..33d5794 --- /dev/null +++ b/client/public/theme.js @@ -0,0 +1,8 @@ +try { + const theme = localStorage.getItem('theme') + if (theme === 'dark' || (!theme && matchMedia('(prefers-color-scheme: dark)').matches)) { + document.documentElement.classList.add('dark') + } +} catch { + // Storage can be unavailable in hardened browser contexts; light mode is safe. +} diff --git a/client/src/App.tsx b/client/src/App.tsx index 95309bf..73fc21d 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,55 +1,121 @@ -import { useEffect, useState } from 'react' -import { BrowserRouter, Routes, Route, Navigate, NavLink } from 'react-router-dom' -import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { lazy, Suspense, useEffect, useRef, useState, type ComponentType } from 'react' +import { BrowserRouter, Navigate, NavLink, Routes, Route, useLocation, useNavigate } from 'react-router-dom' +import { QueryClient, QueryClientProvider, useQuery } from '@tanstack/react-query' +import { + Boxes, + ChartNoAxesColumn, + KeyRound, + LayoutDashboard, + MessageSquareCode, + Moon, + Route as RouteIcon, + Settings2, + ShieldCheck, + Sun, + type LucideProps, +} from 'lucide-react' import { Button } from '@/components/ui/button' import { AppErrorBoundary } from '@/components/app-error-boundary' import { HarborLogo } from '@/components/harbor-logo' -import KeysPage from '@/pages/KeysPage' -import PlaygroundPage from '@/pages/PlaygroundPage' -import FallbackPage from '@/pages/FallbackPage' -import ModelsPage from '@/pages/ModelsPage' -import AnalyticsPage from '@/pages/AnalyticsPage' -import OAuthPage from '@/pages/OAuthPage' -import SettingsPage from '@/pages/SettingsPage' +import { LoadingState } from '@/components/page-header' +import { ApiError, apiFetch } from '@/lib/api' import { cn } from '@/lib/utils' -const queryClient = new QueryClient() +const OverviewPage = lazy(() => import('@/pages/OverviewPage')) +const KeysPage = lazy(() => import('@/pages/KeysPage')) +const PlaygroundPage = lazy(() => import('@/pages/PlaygroundPage')) +const FallbackPage = lazy(() => import('@/pages/FallbackPage')) +const ModelsPage = lazy(() => import('@/pages/ModelsPage')) +const AnalyticsPage = lazy(() => import('@/pages/AnalyticsPage')) +const OAuthPage = lazy(() => import('@/pages/OAuthPage')) +const SettingsPage = lazy(() => import('@/pages/SettingsPage')) -function getInitialDarkMode() { - if (typeof window === 'undefined') return false - const stored = localStorage.getItem('theme') - return stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches) +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 15_000, + retry: (failureCount, error) => { + if (failureCount >= 1) return false + if (!(error instanceof ApiError)) return true + return error.status === 0 || error.status === 408 || error.status === 429 || error.status >= 500 + }, + refetchOnWindowFocus: false, + }, + mutations: { retry: 0 }, + }, +}) + +type Icon = ComponentType + +interface NavItemDefinition { + to: string + label: string + helper: string + icon: Icon } -const navItems = [ - { to: '/playground', label: 'Playground', helper: 'test routes' }, - { to: '/keys', label: 'Keys', helper: 'credentials' }, - { to: '/oauth', label: 'OAuth', helper: 'accounts' }, - { to: '/models', label: 'Models', helper: 'registry' }, - { to: '/fallback', label: 'Routing', helper: 'model order' }, - { to: '/analytics', label: 'Analytics', helper: 'traffic' }, - { to: '/settings', label: 'Settings', helper: 'access policy' }, +const navGroups: Array<{ label: string; items: NavItemDefinition[] }> = [ + { + label: 'Workspace', + items: [ + { to: '/overview', label: 'Overview', helper: 'service health', icon: LayoutDashboard }, + { to: '/playground', label: 'Playground', helper: 'test requests', icon: MessageSquareCode }, + ], + }, + { + label: 'Control plane', + items: [ + { to: '/keys', label: 'Providers & keys', helper: 'credentials', icon: KeyRound }, + { to: '/models', label: 'Models', helper: 'catalog', icon: Boxes }, + { to: '/fallback', label: 'Routing', helper: 'fallback order', icon: RouteIcon }, + { to: '/oauth', label: 'OAuth accounts', helper: 'connected capacity', icon: ShieldCheck }, + ], + }, + { + label: 'Operations', + items: [ + { to: '/analytics', label: 'Analytics', helper: 'traffic & failures', icon: ChartNoAxesColumn }, + { to: '/settings', label: 'Settings', helper: 'policies & backup', icon: Settings2 }, + ], + }, ] -function NavItem({ to, label, helper }: { to: string; label: string; helper: string }) { +const allNavItems = navGroups.flatMap(group => group.items) + +function getInitialDarkMode() { + if (typeof window === 'undefined') return false + try { + const stored = localStorage.getItem('theme') + return stored === 'dark' || (!stored && window.matchMedia('(prefers-color-scheme: dark)').matches) + } catch { + return window.matchMedia('(prefers-color-scheme: dark)').matches + } +} + +function NavItem({ item, compact = false }: { item: NavItemDefinition; compact?: boolean }) { + const Icon = item.icon return ( - cn( - 'group flex min-w-fit shrink-0 items-center gap-3 rounded-2xl px-3 py-2.5 text-sm transition-all outline-none focus-visible:ring-3 focus-visible:ring-ring/40 lg:shrink', - isActive - ? 'bg-muted text-foreground ring-1 ring-border' - : 'text-muted-foreground hover:bg-muted/70 hover:text-foreground', - ) - } + to={item.to} + className={({ isActive }) => cn( + 'group flex min-w-0 items-center gap-2.5 rounded-[var(--radius-button)] text-sm font-medium outline-none transition-colors focus-visible:ring-3 focus-visible:ring-ring/30', + compact ? 'shrink-0 px-3 py-2' : 'w-full px-2.5 py-2', + isActive + ? 'bg-sidebar-accent text-sidebar-accent-foreground' + : 'text-muted-foreground hover:bg-sidebar-accent/60 hover:text-foreground', + )} > {({ isActive }) => ( <> - +