diff --git a/.npmignore b/.npmignore index f3cd24d..18662f3 100644 --- a/.npmignore +++ b/.npmignore @@ -9,6 +9,7 @@ logs/ src/ node_modules/ test/ +docs/ index-test.js diff --git a/AGENTS.md b/AGENTS.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..72ebdce --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,112 @@ +## Project Overview + +Pinggy CLI (`@pinggy/cli`) is a Node.js CLI tool for creating and managing Pinggy tunnels. It runs as **two processes**: a short-lived foreground CLI that the user invokes, and a long-running daemon that owns every tunnel. The CLI talks to the daemon over HTTP + WebSocket on `127.0.0.1`. Built with TypeScript, wraps the `@pinggy/pinggy` SDK in the daemon, and ships a blessed-based TUI plus remote control via WebSocket. + +## Common Commands + +```bash +# Build (produces CJS + ESM in dist/) +npm run build + +# TypeScript type-check only (output to dist_tsc/) +npm run build:tsc + +# Dev workflow (link SDK, build, link locally) +npm run dev + +# Build platform binaries (via pkg) +npm run pack:all + +# E2E suite against a packaged binary (see test/e2e/README.md) +node test/e2e/run.cjs out/pinggy- +``` + +## Architecture + +### Two-process model + +The CLI binary has 3 entry modes, dispatched in `src/main.ts`: + +1. **Daemon child** (`--_daemon-child` flag). Calls `runDaemonChild()` in `src/daemon/daemonChild.ts`. The CLI re-execs itself with this flag when it needs to spawn a daemon; users never invoke it directly. +2. **Subcommand** (`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `d`). Routes to `handleSubcommand()` in `src/cli/subcommands.ts`. +3. **Legacy single-tunnel** (no subcommand). Routes to `buildAndStartTunnel()` for flags like `-l 3000` or `-R0:localhost:3000`. + +The daemon owns the `TunnelManager` singleton, the `@pinggy/pinggy` SDK, the web debugger server, and any `--serve` file servers. The CLI owns argument parsing, the TUI, the remote-management WebSocket client, and the IPC client to the daemon. + +### IPC: HTTP + WebSocket on localhost + +The daemon listens on `127.0.0.1` with an OS-assigned port (recorded in `daemon.json`): + +- **HTTP** for request/response (`src/daemon/ipcServer.ts`): `GET /ping`, `GET /tunnels`, `POST /tunnels/start`, `POST /tunnels/start-config`, `POST /tunnels/stop`, `POST /tunnels/restart`, `POST /shutdown`, plus v1 compatibility routes used by remote management. +- **WebSocket** for streaming tunnel events (schema in `src/daemon/wsProtocol.ts`). Client subscribes by `tunnelId`; daemon emits `tunnel_event` frames keyed by event name. + +CLI code calls `TunnelClient` (`src/daemon/tunnelClient.ts`), the public facade that combines HTTP RPC with WebSocket event dispatch. `IPCClient` (`src/daemon/ipcClient.ts`) is the raw HTTP wrapper underneath; nothing outside `tunnelClient.ts` should touch it. + +### Daemon discovery and lifecycle + +`getDaemonInfo()` in `src/daemon/daemonManager.ts` reads `daemon.json`, validates the PID with `process.kill(pid, 0)`, deletes the file if stale, and returns `null` if no live daemon is found. `startDaemon()` spawns a detached daemon child and polls `daemon.json` for up to 8s. + +Single daemon per user. State lives under `~/.config/pinggy/` on Linux/macOS or `%APPDATA%/pinggy/` on Windows (helper: `src/utils/configDir.ts`): + +- `daemon.json`: `{pid, port, startedAt}`. +- `daemon-state.json`: detached tunnel configs for crash recovery (`src/daemon/stateStore.ts`). Deleted on clean shutdown; replayed on next start. +- `daemon.log`: SDK + daemon logs. CLI logs stay separate. +- `tunnels/_.json`: saved tunnel configs from `pinggy config save`. + +### Foreground vs detached tunnels + +`SessionTracker` (`src/daemon/sessionTracker.ts`) maps each `tunnelId` to a `sessionId` plus a mode: + +- **Foreground**: CLI holds an open WebSocket subscription. If the subscription closes, a 5-second grace period starts; if no other CLI re-attaches, the daemon stops the tunnel. +- **Detached** (`-b` flag, or remote-management tunnels): tunnel persists in the daemon regardless of CLI presence and is recorded in `daemon-state.json`. + +`pinggy attach ` reopens a foreground subscription and renders the TUI. + +**Core types** are in `src/types.ts`: `TunnelStatus`, `Status`, `TunnelStateType` enum (`idle/starting/running/live/closed/exited`), `FinalConfig` (extends SDK's `TunnelConfigurationV1`). Browse `src/daemon/` and `src/cli/` for the rest of the module layout. + +## Subcommands (user-facing) + +| Command | Purpose | +|---|---| +| `pinggy config list \| show \| save \| update \| delete \| auto \| noauto` | CRUD on saved tunnel configs | +| `pinggy start [-b] [--all]` | Start saved tunnel(s). `-b` detaches; `--all` starts every auto-start config | +| `pinggy stop ` | Stop running tunnel(s) by name or ID prefix | +| `pinggy ps` | Table of running tunnels (ID, name, status, local, URL) | +| `pinggy attach ` | Re-attach TUI to a running tunnel | +| `pinggy daemon start \| stop \| status \| install-service \| uninstall-service` (alias `d`) | Daemon lifecycle and system-service installation | + +## Build System + +tsup bundles to `dist/` (CJS + ESM). tsc type-checks only (`npm run build:tsc`). pkg builds standalone platform binaries (`out/pinggy-`). ts-jest with the ESM preset for unit tests (`jest.config.cjs`). + +## Testing + +**Unit tests** live in `src/_tests_/`. Use `@jest/globals` imports (no global `jest`). `TunnelManager` is a singleton, so reset it between tests with `TunnelManager.instance = undefined`, and call `jest.clearAllMocks()` in `beforeEach`. + +**End-to-end tests** live in `test/e2e/`. They run against a packaged `pkg` binary, spawn real Pinggy free-tier tunnels, and assert HTTP/TCP/UDP behavior over the live edge. See `test/e2e/README.md` for layout, framework helpers, and how to add a case. CI runs them across 6 platform binaries via `.github/workflows/e2e-test.yml`. + +## Key Patterns + +- **Singletons** inside the daemon: `TunnelManager`, Winston `logger`, `CLIPrinter`. Access via `getInstance()` or static methods. +- **Listener/observer maps** for SDK callbacks: `Map>`. Register/unregister by `listenerId` to avoid leaks. +- **Zod validation** on remote-management payloads (`src/remote_management/remote_schema.ts`), V1 and V2. +- **Worker threads** for file serving (`src/workers/file_serve_worker.ts`). +- **DaemonTunnelHandler**. Remote management lives in the CLI, but every tunnel operation it receives is forwarded to the daemon via this adapter in `src/daemon/tunnelClient.ts`. Same code path as user-typed subcommands. + +## English Style + +- **No em-dashes.** Use a period, colon, or restructure the sentence. No obvious characters or constructions used by LLMs and AI. +- **Short phrases.** Enough to convey technical meaning. Nothing more. +- **No filler words.** Cut: "in order to", "it is important to note", "please note that", "essentially", "basically", "simply". +- **No passive voice** unless the subject is unknown or irrelevant. +- **No nominalizations.** Prefer "detect" over "perform detection"; "configure" over "apply configuration". +- **One idea per sentence.** Split compound sentences. +- **No throat-clearing openers.** Never start with "This document describes...", "The purpose of this is...", "As mentioned above...". +- **Prefer concrete over abstract.** Name the thing: `navigator.webdriver`, not "the relevant browser property". +- **Present tense.** "The system routes requests." Not "The system will route requests." +- **No redundant qualifiers.** "Persistent storage" not "persistent, durable, long-lived storage". +- **Numbers.** Use digits for all quantities: "3 retries", "50 sessions", "300ms". + +## Code Style +- **No dead code.** Remove unused imports, functions, and variables immediately. `ruff` enforces this. +- **Small functions.** If a function needs a comment to explain its sections, split it. diff --git a/GEMINI.md b/GEMINI.md new file mode 120000 index 0000000..681311e --- /dev/null +++ b/GEMINI.md @@ -0,0 +1 @@ +CLAUDE.md \ No newline at end of file diff --git a/README.md b/README.md index fb61444..e305df5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -# Pinggy CLI +# Pinggy CLI -Create secure, shareable tunnels to your localhost and manage them from the command line. +Create secure, shareable tunnels to your localhost and manage them from the command line. ## Key features @@ -8,8 +8,12 @@ Create secure, shareable tunnels to your localhost and manage them from the comm - SSH-style and user-friendly flags - Web debugger for HTTP tunnels - Extended options for auth, header manipulation, IP allowlists, CORS handling, etc. +- Persistent background daemon owns every tunnel; CLI invocations are short-lived +- Foreground (live TUI) and detached (`-b`) tunnel modes +- Lifecycle commands: `ps`, `start`, `stop`, `restart`, `attach` +- Per-tunnel and per-daemon log files with `pinggy logs` (tail, follow, rotation-safe) +- System-service install for auto-start at boot (systemd, launchd, Task Scheduler) - Remote management via secure WebSocket connection (works with Pinggy Dashboard) -- Configurable logging to file and/or stdout - Save and load configuration files - Config store for saving, listing, updating, and starting named tunnel configs - Auto-start support for launching saved tunnels automatically @@ -17,6 +21,9 @@ Create secure, shareable tunnels to your localhost and manage them from the comm - Built-in TUI (Text User Interface) for viewing tunnel statistics, requests, and responses in real time +## Architecture at a glance +The CLI runs as two processes. A short-lived foreground process is what you invoke. A long-running daemon owns every tunnel and the `@pinggy/pinggy` SDK. They talk over HTTP and WebSocket on `127.0.0.1`. + ## Requirements - Node.js 18+ (recommended). The CLI uses modern ESM and WebSocket features. - A network connection that allows outgoing WebSocket/HTTPS traffic. @@ -38,7 +45,7 @@ After install, verify: ## Quick start - Start a basic HTTP tunnel to localhost:3000: - + ```bash pinggy -R0:localhost:3000 ``` @@ -57,7 +64,164 @@ pinggy -R0:localhost:8000 -L4300:localhost:4300 - Use a token and region/domain-like arg: pinggy mytoken@a.example.com -p 3000. For more info read [docs](https://pinggy.io/docs/) -The CLI prints generated public URLs (HTTP/HTTPS or TCP) and keeps running until you press Ctrl+C. +The CLI prints generated public URLs (HTTP/HTTPS or TCP) and keeps the TUI attached until you press Ctrl+C. Tunnels run inside a background daemon. See [Daemon](#daemon) and [Running tunnels](#running-tunnels) for `ps`, `stop`, and detached (`-b`) mode. + +## Config management + +The CLI includes a built-in config store for saving, listing, and starting tunnel configurations. Configs are persisted as JSON files under the platform config directory (see [State and file locations](#state-and-file-locations)). + +Configs can be looked up by name (exact match) or by configId prefix (partial match) in every `config` and `start`/`stop`/`restart`/`attach` subcommand. + +### Save a tunnel config +```bash +pinggy config save my-tunnel -l 3000 token@pro.pinggy.io +``` + +### Save with auto-start enabled +```bash +pinggy config save my-tunnel --auto -l 3000 +``` + +### List all saved configs +```bash +pinggy config list +pinggy config ls # alias for list +``` + +### View details of a saved config +```bash +pinggy config show my-tunnel +pinggy config show my-tunnel other-tunnel # View multiple configs +pinggy config my-tunnel # Shorthand: same as `config show` +``` + +### Update a saved config +```bash +pinggy config update my-tunnel -l 4000 +``` + +### Enable or disable auto-start +```bash +pinggy config auto my-tunnel +pinggy config noauto my-tunnel +pinggy config auto tunnel1 tunnel2 # Multiple configs at once +``` + +### Delete a saved config +```bash +pinggy config delete my-tunnel +pinggy config delete tunnel1 tunnel2 # Delete multiple +``` + + +## Running tunnels + +Every tunnel runs inside the daemon. The CLI either holds a live TUI subscription to it (foreground) or starts it and exits (detached). + +### Foreground vs detached + +- **Foreground** (default): `pinggy start `. The CLI keeps a WebSocket open to the tunnel and renders the TUI. Use ctrl+C to close the TUI and stop the tunnel. +- **Detached** (`-b`): `pinggy start -b `. The CLI prints the public URL, then exits. The tunnel persists in the daemon until you stop it explicitly with `pinggy stop`. + +### Start a saved tunnel +```bash +pinggy start my-tunnel # foreground, TUI attached +pinggy start -b my-tunnel # detached, CLI exits immediately +``` + +### Start with runtime overrides +```bash +pinggy start my-tunnel -l 4000 +``` + +### Start multiple tunnels +```bash +pinggy start tunnel1 tunnel2 +``` + +> Runtime overrides (`-l`, `--type`, `--token`, ...) only apply when starting a single tunnel. For multiple tunnels, update the saved config first with `pinggy config update`. + +### Start all auto-start tunnels +```bash +pinggy start --all +``` +Runs through the daemon as detached tunnels. Useful for scripting and service startup. + +### Start with remote management +```bash +pinggy start --all --remote-management +pinggy start tunnel1 tunnel2 --remote-management +``` + +### Start with logging enabled +```bash +pinggy start my-tunnel --vvv +pinggy start --all --logfile /tmp/pinggy.log --loglevel DEBUG +``` + +### List running tunnels +```bash +pinggy ps +``` +Prints a table of ID, name, status, local endpoint, and public URL. + +### Stop tunnels +```bash +pinggy stop my-tunnel +pinggy stop my-tunnel other-tunnel # multiple +pinggy stop abc12345 # by configId prefix +``` + +### Restart a tunnel +```bash +pinggy restart my-tunnel +``` +Preserves the existing mode (foreground stays foreground, detached stays detached). + +### Re-attach the TUI to a running tunnel +```bash +pinggy attach my-tunnel +``` +Opens a fresh TUI session against a tunnel that is already running. Useful to inspect a detached tunnel live. + + +## Daemon + +The daemon is the long-running process that owns every tunnel. The CLI starts it automatically when needed, so most users never call these commands directly. They exist for explicit control, scripting, and boot-time service install. + +Both `pinggy daemon` and the alias `pinggy d` work. + +### Start the daemon +```bash +pinggy daemon start +``` +Lists which configs will auto-start (any tagged with `config auto`). + +### Stop the daemon +```bash +pinggy daemon stop +``` +Stops every running tunnel and shuts the daemon down cleanly. + +### Show daemon status +```bash +pinggy daemon status +``` +Prints PID, port, start time, and uptime. + + +## Remote management +You can control tunnels remotely using a secure WebSocket connection. + +- Start remote management with a token: +```bash + pinggy --remote-management +``` + +- Specify a management server (default is wss://dashboard.pinggy.io): +```bash + pinggy --remote-management --manage wss://custom.example.com +``` ## Usage @@ -99,6 +263,8 @@ The CLI supports both SSH-style flags and more descriptive long flags. Below is | `--vv` | Detailed logs (Node.js SDK + Libpinggy) | | `--vvv` | Enable logs from CLI, SDK, and Libpinggy | +These flags apply to the CLI invocation. For daemon-wide log level and per-tunnel log files, see [Logging](#logging). + --- ### **Config (File-based)** @@ -132,6 +298,15 @@ The CLI supports both SSH-style flags and more descriptive long flags. Below is --- +### **Tunnel lifecycle** +| Flag | Description | +|------|-------------| +| `-b` | Start the tunnel detached (daemon keeps it alive after the CLI exits). Pairs with `pinggy start`. | +| `--all` | Start every config marked auto-start. Pairs with `pinggy start`. | +| `--auto` | Mark a saved config as auto-start. Pairs with `pinggy config save`. | + +--- + ### **Misc** | Flag | Description | |------|-------------| @@ -164,35 +339,9 @@ Examples: To generate advanced CLI arguments, use [Configure from Pinggy.io](https://pinggy.io/) -## Remote management -You can control tunnels remotely using a secure WebSocket connection. - -- Start remote management with a token: -```bash - pinggy --remote-management -``` - -- Specify a management server (default is wss://dashboard.pinggy.io): -```bash - pinggy --remote-management --manage wss://custom.example.com -``` - - - -## Logging -You can control logs via CLI flags (which override environment variables). If logfile is provided, the log directory will be created if it does not exist. - -- To log to file and stdout at INFO level: -```bash - pinggy -p 3000 --logfile ~/.pinggy/pinggy.log --loglevel INFO --v -``` -If you provide `--v`, `--vv`, or `--vvv` without specifying a log level, the default log level is INFO. - - - ## Saving and loading configuration - Save current options to a file: -```bash +```bash pinggy -p 443 -L4300:localhost:4300 -t -R0:127.0.0.1:8000 qr+force@free.pinggy.io x:noreverseproxy x:passpreflight x:xff --saveconf myconfig.json ``` - Use a config as base and override with flags: @@ -201,93 +350,59 @@ pinggy --conf ./myconfig.json -p 8080 ``` -## Config management - -The CLI includes a built-in config store for saving, listing, and starting tunnel configurations. Configs are persisted as JSON files in your platform's config directory (`~/.config/pinggy/tunnels/` on Linux/macOS, `%APPDATA%/pinggy/tunnels/` on Windows). - -### Save a tunnel config -```bash -pinggy config save my-tunnel -l 3000 token@pro.pinggy.io -``` - -### Save with auto-start enabled -```bash -pinggy config save my-tunnel --auto -l 3000 -``` - -### List all saved configs -```bash -pinggy config list -``` +## Logging -### View details of a saved config -```bash -pinggy config show my-tunnel -pinggy config show my-tunnel other-tunnel # View multiple configs -``` +The CLI has two layers of logging: per-invocation flags that affect what the current command prints, and daemon-wide log commands that read the persistent log files the daemon writes. -### Update a saved config -```bash -pinggy config update my-tunnel -l 4000 -``` +### Per-invocation flags +Pass these on any command that starts or interacts with a tunnel. -### Enable or disable auto-start ```bash -pinggy config auto my-tunnel -pinggy config noauto my-tunnel -pinggy config auto tunnel1 tunnel2 # Multiple configs at once +pinggy -p 3000 --logfile ~/.pinggy/pinggy.log --loglevel INFO --v ``` +If you pass `--v`, `--vv`, or `--vvv` without a log level, the default is INFO. If a logfile path is provided, the log directory is created if it does not exist. -### Delete a saved config -```bash -pinggy config delete my-tunnel -pinggy config delete tunnel1 tunnel2 # Delete multiple -``` +### Daemon and per-tunnel log files +The daemon writes its own log file and a separate log per tunnel under the platform log directory (see [State and file locations](#state-and-file-locations)). -### Shorthand: view config details +#### Tail the daemon log ```bash -pinggy config my-tunnel # Same as: pinggy config show my-tunnel +pinggy logs # last 100 lines of the daemon log +pinggy logs -f # follow new daemon log lines ``` -Configs can be looked up by name (exact match) or by configId prefix (partial match). - - -## Starting saved tunnels - -### Start a saved tunnel +#### Tail a tunnel log ```bash -pinggy start my-tunnel +pinggy logs my-tunnel # last 100 lines of that tunnel's log +pinggy logs my-tunnel -f # follow new lines (survives log rotation) ``` -### Start with runtime overrides +#### Print the log file path ```bash -pinggy start my-tunnel -l 4000 +pinggy log path # daemon log path +pinggy log path my-tunnel # path to a specific tunnel's log ``` -### Start multiple tunnels +#### Get or set the daemon log level ```bash -pinggy start tunnel1 tunnel2 +pinggy log level # print current level +pinggy log level debug # set to debug, info, or error ``` +Setting the level persists in `daemon-config.json` and applies to the daemon and any new tunnels. To pick up the new level on a tunnel that is already running, restart it with `pinggy restart `. -### Start all auto-start tunnels -```bash -pinggy start --all -``` -### Start with remote management -```bash -pinggy start --all --remote-management -pinggy start tunnel1 tunnel2 --remote-management -``` +## State and file locations -### Start with logging enabled -```bash -pinggy start my-tunnel --vvv -pinggy start --all --logfile /tmp/pinggy.log --loglevel DEBUG -``` +Config dir varies by OS: +- Linux/macOS: `~/.config/pinggy/` +- Windows: `%APPDATA%\pinggy\` -> **Note:** Runtime overrides (`-l`, `--type`, `--token`, etc.) can only be used when starting a single tunnel. For multiple tunnels, update the saved config first with `pinggy config update`. +Log dir varies by OS: +- Linux: `~/.local/state/pinggy-cli/logs/` (honors `$XDG_STATE_HOME`) +- macOS: `~/Library/Logs/Pinggy-CLI/` +- Windows: `%LOCALAPPDATA%\Pinggy-CLI\Logs\` +Use `pinggy log path` to print the exact resolved paths on your system. ## File server mode Serve a local directory quickly over a tunnel: @@ -296,8 +411,10 @@ Optionally combine with other flags (auth, IP whitelist) as needed. ## Signals and shutdown -Press Ctrl+C to stop. The CLI traps SIGINT and gracefully stops active tunnels before exiting. +- **Foreground tunnel**: Ctrl+C closes the TUI. The daemon arms a 5-second grace timer and stops the tunnel if no other CLI re-attaches. +- **Detached tunnel** (`-b`): the CLI already exited. Stop it with `pinggy stop `. +- **Everything at once**: `pinggy daemon stop` stops every tunnel and shuts the daemon down cleanly. `daemon-state.json` is cleared, so nothing replays on next start. ## Versioning diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..f4f5ab1 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,108 @@ +# Pinggy CLI Architecture +Reference for developers and AI agents working on the `@pinggy/cli` codebase. Covers the two-process daemon model, IPC, state, session ownership. + +For domain language (Tunnel, Daemon, Client, Origin, Foreground mode, etc.) see `CONTEXT.md`. For project rules and English style see `CLAUDE.md`. This file documents how the pieces fit together. + +## 1. The two-process model + +The `pinggy` binary has three execution modes, dispatched in `src/main.ts`: + +| Mode | Trigger | Entry | Purpose | +| -------------------- | -------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Daemon child | `--_daemon-child` flag | `runDaemonChild()` in `src/daemon/daemonChild.ts` | Long-running background process. Owns the `TunnelManager` and the SDK. | +| Subcommand | First arg in `SUBCOMMANDS` set | `handleSubcommand()` in `src/cli/subcommands.ts` | Short-lived CLI invocation:`config`, `start`, `stop`, `ps`, `attach`, `daemon`, `logs`, `log`, `restart`. | +| Legacy single-tunnel | No subcommand, has flags | `buildAndStartTunnel()` in `src/cli/buildAndStartTunnel.ts` | Backwards-compatible single-shot tunnel for flags like `-l 3000` or `-R0:...`. | + +## 2. Filesystem layout + +State lives under `getPinggyConfigDir()` (`src/utils/configDir.ts`): + +- Linux: `$XDG_CONFIG_HOME/pinggy` (default `~/.config/pinggy`) +- macOS: `~/.config/pinggy` +- Windows: `%APPDATA%/pinggy` + +Logs live under `getPinggyLogDir()`: + +- Linux: `$XDG_STATE_HOME/pinggy-cli/logs` +- macOS: `~/Library/Logs/Pinggy-CLI` +- Windows: `%LOCALAPPDATA%/Pinggy-CLI/Logs` + +## 3. Daemon discovery and spawn + +Every CLI invocation that touches a tunnel asks one question first: is there a live daemon to talk to? `getDaemonInfo()` answers it by reading `daemon.json` and validating the recorded PID with `process.kill(pid, 0)`. Signal 0 never delivers anything; it just throws when the target process is gone, which is exactly the liveness probe we need. A missing file, malformed JSON, or a dead PID all collapse to the same result: return `null`, and let `ensureDaemonRunning()` spawn a fresh child. + +The spawn path is deliberately one-way. The CLI fires off a detached child with the `--_daemon-child` flag and then polls `daemon.json` until the child writes it. The poll budget is 8 seconds at 200ms intervals; a timeout almost always means the SDK failed to load inside the child, in which case the stderr captured from the spawned process is surfaced in the thrown error. Atomic writes of `daemon.json` (write to `.tmp`, then `fs.renameSync`) keep concurrent readers from ever seeing a half-written file. + +Implementation: `src/daemon/daemonManager.ts`. + +## 4. IPC: HTTP routes + +HTTP carries every request/response operation: start, stop, list, restart, log management, shutdown. The transport is plain `http` on `127.0.0.1` with an OS-assigned port. Loopback isolation is the security boundary; there is no auth on these routes, so binding anywhere other than `127.0.0.1` is unsafe. + +Server: `src/daemon/ipcServer.ts`. Client: `src/daemon/ipcClient.ts`. Public facade: `src/daemon/tunnelClient.ts`. + +Conventions: + +- Every request sets `X-Pinggy-Origin: app|cli` (defaulted in `parseOrigin()` to `cli`). Stored on the tunnel and used in log filenames. + +- `start` is idempotent on `configId`. If a tunnel with the same `configId` is already running, the daemon returns `ErrorResponse{code: TunnelAlreadyRunningError}` and the CLI prints the existing state instead of starting a duplicate. +- A successful operation returns its typed response with HTTP 200. An expected application-level failure returns `ErrorResponse` JSON, still with HTTP 200; the route handled the request, the operation just failed. Non-200 means a transport-level problem (daemon died mid-request, port in use after spawn, etc.) and surfaces as a thrown error in `IPCClient`. +- The `mode` field (added to `start`, `start-config`, `start-v1`) tells `trackIPCTunnelStart` whether to persist the tunnel to `daemon-state.json`. Foreground tunnels are not persisted; detached tunnels are. The default is `"detached"`, which is what remote management and `DaemonTunnelHandler` rely on. + +## 5. IPC: WebSocket event stream + +Path: `ws://127.0.0.1:/ws`. Schema in `src/daemon/wsProtocol.ts`. + +Client → Daemon: + +``` +{ type: "subscribe", tunnelId: "...", mode: "foreground" | "detached" } +{ type: "unsubscribe", tunnelId: "..." } +``` + +Daemon → Client (always wrapped in a `tunnel_event`): + +``` +{ + type: "tunnel_event", + tunnelId: "...", + event: "url_ready" | "stats" | "disconnect" | "reconnecting" | "reconnected" + | "reconnection_failed" | "error" | "stopped" | "will_reconnect" + | "worker_error" | "subscribed" | "error_response", + payload: { ... } +} +``` + +On `subscribe`, the daemon registers a fan-out of listeners against `TunnelManager` for the tunnel and remembers their listener IDs on the session. On `unsubscribe` or session close, every listener is deregistered. See `handleSubscribe`, `deregisterListeners`, `cleanupSession` in `ipcServer.ts`. + +## 7. Foreground vs detached: SessionTracker + +`SessionTracker` in `src/daemon/sessionTracker.ts` tracks ownership of each running tunnel. + +``` +ownership: Map +graceTimers: Map +``` +Key invariants: + +- Foreground tunnels die 5 seconds after their WS session disconnects if nobody re-attaches. Implemented as `startGraceTimer` → `killOrphanedTunnel` in `SessionTracker`. +- Detached tunnels are immune to session disconnect and persist in `daemon-state.json` for crash recovery. +- `attach(tunnelId, sessionId, mode)` cancels any pending grace timer for that tunnel. +- `pinggy attach ` opens a fresh foreground subscription; if the tunnel was in detached mode, the call still succeeds, the timer is not armed (mode stays foreground while subscribed). + +## 8. Origin tagging and log file naming + +When a tunnel is created, the IPCServer reads the `X-Pinggy-Origin` header and passes it to `TunnelManager.createTunnel(config, origin)`. The origin is stored on `ManagedTunnel.origin` and is used by `getTunnelLogPath(tunnelId, origin, name)`: + +``` +__.log when name present +__.log when nameless +``` + +Example: `cli__my-api.log`, `app__1700000000_xyz789.log`. + +This is how a shared daemon distinguishes tunnels from different clients on disk. The log viewers in the desktop app filter by the origin prefix. + +## 9 Tunnel lifecycle end-to-end + +see the flowchart in [TUNNEL_LIFECYCLE.md](TUNNEL_LIFECYCLE.md) \ No newline at end of file diff --git a/docs/TUNNEL_LIFECYCLE.md b/docs/TUNNEL_LIFECYCLE.md new file mode 100644 index 0000000..71349c7 --- /dev/null +++ b/docs/TUNNEL_LIFECYCLE.md @@ -0,0 +1,141 @@ +## End-to-End Architecture + +Everything in one diagram: entry dispatch, daemon guards and discovery, daemon boot, IPC paths, tunnel state, foreground vs detached ownership, the 5-second grace timer, remote management, health-based daemon-loss detection, clean shutdown, and crash recovery. Red nodes are failure states; green nodes are healthy steady states. + +```mermaid + +flowchart TD + %% Entry + Entry([User invokes pinggy]) --> Parse["Parse argv
src/main.ts"] + Parse --> Mode{"Dispatch mode"} + + Mode -->|"--_daemon-child"| DCBoot + Mode -->|"subcommand"| HandleSub["handleSubcommand
src/cli/subcommand/subcommands.ts"] + Mode -->|"legacy flags"| Legacy["buildAndStartTunnel"] + + HandleSub --> Branch{"Touches a tunnel?"} + Legacy --> Branch + Branch -->|"yes: start, stop, ps, attach, restart"| Guard + Branch -->|"no: config save/list/delete/auto"| ConfigOnly["configStore
tunnels/*.json"] + ConfigOnly --> CDone([CLI exits]) + + %% Daemon guards / discovery + subgraph Guards["Daemon discovery (daemonManager.ts)"] + direction TB + Guard["ensureDaemonRunning"] --> Read["read daemon.json"] + Read --> AliveQ{"PID alive?
process.kill pid 0"} + AliveQ -->|"alive"| Use["use existing daemon"] + AliveQ -->|"dead or missing"| Clean["unlink stale
daemon.json"] + Clean --> SpawnIt["spawn detached child
--_daemon-child"] + SpawnIt --> PollIt["poll daemon.json
200ms, 8s cap"] + PollIt -->|"ready"| Use + PollIt -->|"timeout"| FailStart([Error: daemon
failed to start]) + end + + %% Daemon boot + subgraph DaemonBoot["Daemon boot (daemonChild.ts) - runs in spawned process"] + direction TB + DCBoot["runDaemonChild"] --> EnsureDir["ensureConfigDir"] + EnsureDir --> LoadCfg["load daemonConfig
log level"] + LoadCfg --> ListenIPC["ipcServer.listen
127.0.0.1, OS-assigned port"] + ListenIPC --> AtomicWrite["atomic write daemon.json
tmp + rename"] + AtomicWrite --> RestoreQ{"daemon-state.json
present?"} + RestoreQ -->|"yes, crash detected"| Replay["restoreCrashedTunnels
detached only"] + RestoreQ -->|"no, clean prior exit"| AutoCfg + Replay --> AutoCfg["start auto-start configs"] + AutoCfg --> Sig["install SIGTERM,
SIGINT,
uncaughtException handlers"] + Sig --> Idle([Daemon idle,
serving IPC]) + end + + SpawnIt -. "new process
(re-execs binary with
-- _daemon-child)" .->DCBoot + AtomicWrite -. "daemon.json visible
to CLI's poll loop" .-> PollIt + + %% CLI to daemon transport + Use --> TC["TunnelClient
tunnelClient.ts"] + TC -->|"HTTP RPC"| HTTP["ipcRoutes.ts
/tunnels, /tunnels/start,
/start-config, /stop,
/restart, /shutdown, /ping"] + TC -->|"WebSocket"| WSP["wsProtocol.ts
subscribe / unsubscribe"] + TC -. "heartbeat" .-> Health["DaemonHealth"] + + HTTP --> Mgr["TunnelManager singleton"] + WSP --> Sess["SessionTracker"] + + %% Tunnel state machine + Mgr --> SDK["@pinggy/pinggy SDK
TunnelInstance"] + SDK -. "callbacks" .-> Mgr + Mgr --> SM{"State transition"} + SM -->|"create"| StIdle["idle"] + SM -->|"instance.start"| StStart["starting"] + SM -->|"established"| StRun["running"] + SM -->|"disconnect /
will_reconnect"| StRec["reconnecting"] + StRec -->|"reconnected"| StRun + SM -->|"stopTunnel ok"| StCls["closed"] + SM -->|"fatal err,
worker_error,
reconnection_failed"| StExi([exited]) + + Mgr --> Bcast["broadcast tunnel_event"] + Bcast --> WSout["WS frames to subscribers:
url_ready, stats,
disconnect, reconnecting,
reconnected, stopped, error"] + + %% Ownership + Sess --> Own{"Subscription mode"} + Own -->|"foreground"| FG["Foreground session
CLI WS open"] + Own -->|"detached (-b or remote)"| Det["Detached owner
no CLI required"] + + FG --> TUI["Blessed TUI
src/cli/startCli.ts"] + Det --> Persist["stateStore
writes daemon-state.json"] + + %% Foreground exit + grace + TUI -->|"Ctrl+C / quit"| Unsub["WS unsubscribe"] + Unsub --> RemSess["SessionTracker
removes session"] + RemSess --> Last{"last foreground
session?"} + Last -->|"no, other CLI attached"| StayUp["tunnel keeps running"] + Last -->|"yes"| Timer["startGraceTimer
(5 seconds)"] + Timer --> Retry{"attach
within 5s?"} + Retry -->|"yes"| CancelT["cancel grace timer"] + Retry -->|"no"| Kill([killOrphanedTunnel]) + CancelT --> FG + Kill --> Mgr + StayUp --> WSout + + %% Remote management + subgraph Remote["Remote management (in CLI process)"] + direction TB + Cloud([Pinggy cloud WS]) --> Validate["Zod V1 / V2 schema
remote_schema.ts"] + Validate -->|"invalid"| Reject([reject + error response]) + Validate -->|"valid"| DTH["DaemonTunnelHandler
daemonTunnelHandler.ts"] + end + DTH -. "same HTTP routes" .-> HTTP + + %% Health-based daemon loss + Health -->|"daemon gone"| Lost["onDaemonLost fires"] + Lost --> Exit3([CLI exits with code 3]) + + %% Clean shutdown + Idle --> ShutSig{"shutdown trigger"} + ShutSig -->|"SIGTERM / SIGINT"| Clean2["cleanup"] + ShutSig -->|"POST /shutdown"| Clean2 + ShutSig -->|"uncaughtException"| Clean2 + Clean2 --> R1["unlinkSync daemon.json
(blocks new CLIs)"] + R1 --> R2["clear daemon-state.json
(marks clean exit)"] + R2 --> R3["SessionTracker.destroy
cancel all grace timers"] + R3 --> R4["stop all tunnels"] + R4 --> R5["ipcServer.close"] + R5 --> Done([process.exit 0]) + + %% Crash path + Idle -. "process dies,
no cleanup runs" .-> Dead([daemon dead
daemon-state.json remains]) + Dead -. "next pinggy invocation" .-> Read + + %% Service mode entry (optional) + Mode -.-> D["Work in progress"] + D -.->|"daemon install-service /
uninstall-service"| Svc["service installer
systemd / launchd / Win SCM"] + Svc -.-> Sys([OS spawns daemon-child
at boot]) + Sys -. "same path" .-> DCBoot + + %% Styling + classDef failure fill:#ffe5e5,stroke:#c0392b,color:#000 + classDef ok fill:#e8f6ee,stroke:#27ae60,color:#000 + classDef state fill:#eef4ff,stroke:#3a6ea5,color:#000 + class FailStart,Exit3,Lost,StExi,Dead,Reject,Kill failure + class Done,Idle,CDone,Sys,Sig ok + class StIdle,StStart,StRun,StRec,StCls state + +``` \ No newline at end of file diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..213f0f8 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,27 @@ +import tseslint from "@typescript-eslint/eslint-plugin"; +import parser from "@typescript-eslint/parser"; + +export default [ + { + ignores: ["**/_tests_/**"], + }, + { + files: ["**/*.ts"], + languageOptions: { + parser, + parserOptions: { + project: "./tsconfig.json", + }, + }, + plugins: { + "@typescript-eslint": tseslint, + }, + rules: { + "@typescript-eslint/no-explicit-any": "error", + "@typescript-eslint/no-floating-promises": "error", + "@typescript-eslint/await-thenable": "error", + "@typescript-eslint/require-await": "warn", + "no-duplicate-imports": "error", + }, + }, +]; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dbaa064..5bac803 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,15 +1,15 @@ { "name": "pinggy", - "version": "0.4.9", + "version": "0.5.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "pinggy", - "version": "0.4.9", + "version": "0.5.0", "license": "Apache-2.0", "dependencies": { - "@pinggy/pinggy": "^0.4.6", + "@pinggy/pinggy": "^0.4.8", "blessed": "^0.1.81", "clipboardy": "^5.0.0", "mime": "^4.1.0", @@ -29,7 +29,10 @@ "@types/qrcode": "^1.5.6", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", "@yao-pkg/pkg": "^6.11.0", + "eslint": "^10.4.0", "jest": "^30.1.2", "ts-jest": "^29.4.1", "ts-node": "^10.9.2", @@ -1075,6 +1078,205 @@ "node": ">=18" } }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-array/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@eslint/config-array/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.1.tgz", + "integrity": "sha512-rZAP3aVgB9ds9KOeUSL+zZ21hPmo8dh6fnIFwRQj5EAZl9gzR7wxYbYXYysAM8CTqGmUGyp2S4kUdV17MnGuWQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1558,9 +1760,9 @@ } }, "node_modules/@pinggy/pinggy": { - "version": "0.4.6", - "resolved": "https://registry.npmjs.org/@pinggy/pinggy/-/pinggy-0.4.6.tgz", - "integrity": "sha512-Kwt2mf2cx0LNfYEYfMWFBlAxoMGfsHzZiTsjm0CHAq5p/Mx9wh7EMVQjLvZl7MBdWDgUCmj9fD7sBbxSLhlkqQ==", + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@pinggy/pinggy/-/pinggy-0.4.8.tgz", + "integrity": "sha512-IPAmzyiZ/DJQAY8LdacRJRukWywiAV1YcMUgLs8XwiCJHU+Q+3CRerIMnz8dnFSiE7eQ0gXD30f2K1a2tFlyEw==", "hasInstallScript": true, "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", @@ -2104,6 +2306,13 @@ "@types/node": "*" } }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/estree": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", @@ -2149,6 +2358,13 @@ "pretty-format": "^30.0.0" } }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/node": { "version": "24.12.3", "resolved": "https://registry.npmjs.org/@types/node/-/node-24.12.3.tgz", @@ -2217,6 +2433,279 @@ "dev": true, "license": "MIT" }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.60.0.tgz", + "integrity": "sha512-QYb/sa74/s7OKMbACMjrYnGspj9Hs5YI5aaffSL65UfeBUzVzBJfVo3oWSpbzPurvm7yaCCo2Lk7lVj610HqKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/type-utils": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.60.0", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.60.0.tgz", + "integrity": "sha512-fcqpj/MyK4sxDPcbe7STNPbpQL4RLZOPWuaTmwZYuc+hJKzRf58yRxfhqGpc6PIq9ZyfSBpfHgmUHmHs0KwHwg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.60.0.tgz", + "integrity": "sha512-aZu74NNKJeUWqCjDddzdiKaS82dgYgV/vmf+Ui3ZdZejmgfXR/q+pRumgobnQ2cCJTgGTWp4ypiwsuofFubavg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.60.0", + "@typescript-eslint/types": "^8.60.0", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.60.0.tgz", + "integrity": "sha512-pFzqhllJMs+jghLQWzV00ds39xLzuyqPSev5pd8f4Ir0rtKR3ZLUB4/4dhjOFighWb9larvtfJvqL+4yKDI3Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.60.0.tgz", + "integrity": "sha512-BZPR3RGYlAXnly6ymAxfkVn5rCbZzQNou0rxv3GfWZ8cTQp+hhVd73khbGLAd8k1TlAPLISH337M+tAgAnaJDQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.60.0.tgz", + "integrity": "sha512-SX46wEUtitCpq7AN38HkUU/+zvUpdKf7ephtWAFgckH8O7PQIyL5gvrhQgBLuEYgLfuKWOVvWVskMbuFHAz5xg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0", + "@typescript-eslint/utils": "8.60.0", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.60.0.tgz", + "integrity": "sha512-AsE7x2XaAK+CVbeih0Fvbn+r1qHxtpLDJ3XUuFcIinT318T90yHMJC+Zgv+jUuDjQQd06HKwxnDu6sz1IcTilA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.60.0.tgz", + "integrity": "sha512-3AcZNBGMClm6CXDyo8kYvVGT/sx29sS0oBsIb9oZI2gunA4Vm2M3YHzRLPvsUBBsl+yB5FPtltq7gGH0iTlp9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.60.0", + "@typescript-eslint/tsconfig-utils": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/visitor-keys": "8.60.0", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.60.0.tgz", + "integrity": "sha512-HtXuPfrHTyBDkameWpl+vJb1Uevu2tznAyahM1Oc4AENidCLTPiZDWIo4GfcxNdC/RcfGcadzzkqbRG87dUrQA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.60.0", + "@typescript-eslint/types": "8.60.0", + "@typescript-eslint/typescript-estree": "8.60.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.60.0", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.60.0.tgz", + "integrity": "sha512-9WI52t8ZGLVGrPMBet25yAftqY/n95+zmoUUtJBBQTKDSKUu7OsPTroT2op7U9JatkoRccL0YkWDNMFfC4Sjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.60.0", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, "node_modules/@ungap/structured-clone": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/@ungap/structured-clone/-/structured-clone-1.3.1.tgz", @@ -2588,6 +3077,7 @@ "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", "dev": true, "license": "MIT", + "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -2595,6 +3085,16 @@ "node": ">=0.4.0" } }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, "node_modules/acorn-walk": { "version": "8.3.5", "resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz", @@ -2617,6 +3117,23 @@ "node": ">= 14" } }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, "node_modules/ansi-escapes": { "version": "4.3.2", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-4.3.2.tgz", @@ -3608,6 +4125,13 @@ "node": ">=4.0.0" } }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, "node_modules/deepmerge": { "version": "4.3.1", "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", @@ -3746,96 +4270,340 @@ "once": "^1.4.0" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/esbuild": { + "version": "0.27.7", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", + "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "peer": true, + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.27.7", + "@esbuild/android-arm": "0.27.7", + "@esbuild/android-arm64": "0.27.7", + "@esbuild/android-x64": "0.27.7", + "@esbuild/darwin-arm64": "0.27.7", + "@esbuild/darwin-x64": "0.27.7", + "@esbuild/freebsd-arm64": "0.27.7", + "@esbuild/freebsd-x64": "0.27.7", + "@esbuild/linux-arm": "0.27.7", + "@esbuild/linux-arm64": "0.27.7", + "@esbuild/linux-ia32": "0.27.7", + "@esbuild/linux-loong64": "0.27.7", + "@esbuild/linux-mips64el": "0.27.7", + "@esbuild/linux-ppc64": "0.27.7", + "@esbuild/linux-riscv64": "0.27.7", + "@esbuild/linux-s390x": "0.27.7", + "@esbuild/linux-x64": "0.27.7", + "@esbuild/netbsd-arm64": "0.27.7", + "@esbuild/netbsd-x64": "0.27.7", + "@esbuild/openbsd-arm64": "0.27.7", + "@esbuild/openbsd-x64": "0.27.7", + "@esbuild/openharmony-arm64": "0.27.7", + "@esbuild/sunos-x64": "0.27.7", + "@esbuild/win32-arm64": "0.27.7", + "@esbuild/win32-ia32": "0.27.7", + "@esbuild/win32-x64": "0.27.7" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", + "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/eslint": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.4.0.tgz", + "integrity": "sha512-loXy6bWOoP3EP6JA7jo6p5jMpBJmHmsNZM5SFRHLdh1MGOPurMnNBj4ZlAbaqUAaQWbCr7jHV4P7gzAyryZWkQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/eslint/node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint/node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint/node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, "license": "MIT", "engines": { - "node": ">=6" + "node": ">= 4" } }, - "node_modules/error-ex": { - "version": "1.3.4", - "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", - "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "node_modules/eslint/node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", "dev": true, "license": "MIT", "dependencies": { - "is-arrayish": "^0.2.1" + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "node_modules/eslint/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", "dev": true, - "license": "MIT", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, "engines": { - "node": ">= 0.4" + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/esbuild": { - "version": "0.27.7", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.27.7.tgz", - "integrity": "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w==", + "node_modules/eslint/node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", "dev": true, - "hasInstallScript": true, "license": "MIT", - "peer": true, - "bin": { - "esbuild": "bin/esbuild" + "dependencies": { + "p-limit": "^3.0.2" }, "engines": { - "node": ">=18" + "node": ">=10" }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.27.7", - "@esbuild/android-arm": "0.27.7", - "@esbuild/android-arm64": "0.27.7", - "@esbuild/android-x64": "0.27.7", - "@esbuild/darwin-arm64": "0.27.7", - "@esbuild/darwin-x64": "0.27.7", - "@esbuild/freebsd-arm64": "0.27.7", - "@esbuild/freebsd-x64": "0.27.7", - "@esbuild/linux-arm": "0.27.7", - "@esbuild/linux-arm64": "0.27.7", - "@esbuild/linux-ia32": "0.27.7", - "@esbuild/linux-loong64": "0.27.7", - "@esbuild/linux-mips64el": "0.27.7", - "@esbuild/linux-ppc64": "0.27.7", - "@esbuild/linux-riscv64": "0.27.7", - "@esbuild/linux-s390x": "0.27.7", - "@esbuild/linux-x64": "0.27.7", - "@esbuild/netbsd-arm64": "0.27.7", - "@esbuild/netbsd-x64": "0.27.7", - "@esbuild/openbsd-arm64": "0.27.7", - "@esbuild/openbsd-x64": "0.27.7", - "@esbuild/openharmony-arm64": "0.27.7", - "@esbuild/sunos-x64": "0.27.7", - "@esbuild/win32-arm64": "0.27.7", - "@esbuild/win32-ia32": "0.27.7", - "@esbuild/win32-x64": "0.27.7" + "funding": { + "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", "dev": true, - "license": "MIT", + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, "engines": { - "node": ">=6" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, - "node_modules/escape-string-regexp": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-2.0.0.tgz", - "integrity": "sha512-UpzcLCXolUWcNu5HtVMHYdXJjArjsF9C0aNnquZYY4uW/Vu0miy5YoWvbV345HauVvcAUnpRuhMMcqTcGOY2+w==", + "node_modules/espree/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", "dev": true, - "license": "MIT", + "license": "Apache-2.0", "engines": { - "node": ">=8" + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" } }, "node_modules/esprima": { @@ -3852,6 +4620,52 @@ "node": ">=4" } }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -3932,6 +4746,13 @@ "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, "node_modules/fast-fifo": { "version": "1.3.2", "resolved": "https://registry.npmjs.org/fast-fifo/-/fast-fifo-1.3.2.tgz", @@ -3946,6 +4767,13 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, "node_modules/fb-watchman": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/fb-watchman/-/fb-watchman-2.0.2.tgz", @@ -3994,6 +4822,19 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/find-up": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", @@ -4019,6 +4860,27 @@ "rollup": "^4.34.8" } }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, "node_modules/fn.name": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/fn.name/-/fn.name-1.1.0.tgz", @@ -4170,6 +5032,19 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -4271,6 +5146,16 @@ ], "license": "BSD-3-Clause" }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, "node_modules/import-local": { "version": "3.2.0", "resolved": "https://registry.npmjs.org/import-local/-/import-local-3.2.0.tgz", @@ -4377,6 +5262,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-fullwidth-code-point": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", @@ -4396,6 +5291,19 @@ "node": ">=6" } }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/is-inside-container": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", @@ -5404,6 +6312,13 @@ "node": ">=6" } }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, "node_modules/json-parse-even-better-errors": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", @@ -5411,6 +6326,20 @@ "dev": true, "license": "MIT" }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, "node_modules/json5": { "version": "2.2.3", "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", @@ -5437,6 +6366,16 @@ "graceful-fs": "^4.1.6" } }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, "node_modules/kuler": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", @@ -5453,6 +6392,20 @@ "node": ">=6" } }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/lilconfig": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/lilconfig/-/lilconfig-3.1.3.tgz", @@ -5993,6 +6946,24 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/p-limit": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", @@ -6347,6 +7318,16 @@ "node": ">=6" } }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/pretty-format": { "version": "30.4.1", "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-30.4.1.tgz", @@ -6428,6 +7409,16 @@ "once": "^1.3.1" } }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, "node_modules/pure-rand": { "version": "7.0.1", "resolved": "https://registry.npmjs.org/pure-rand/-/pure-rand-7.0.1.tgz", @@ -7633,6 +8624,19 @@ "node": ">= 14.0.0" } }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, "node_modules/ts-interface-checker": { "version": "0.1.13", "resolved": "https://registry.npmjs.org/ts-interface-checker/-/ts-interface-checker-0.1.13.tgz", @@ -7860,6 +8864,19 @@ "node": "*" } }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, "node_modules/type-detect": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz", @@ -8052,6 +9069,16 @@ "browserslist": ">= 4.21.0" } }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -8175,6 +9202,16 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/wordwrap": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/wordwrap/-/wordwrap-1.0.0.tgz", diff --git a/package.json b/package.json index ffc0276..e439a90 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "pinggy", - "version": "0.4.9", + "version": "0.5.0", "license": "Apache-2.0", "type": "module", "description": "Create secure, shareable tunnels to your localhost and manage them from the command line. ", @@ -11,8 +11,9 @@ "pinggy": "dist/index.cjs" }, "scripts": { + "lint": "eslint src --ext .ts", "build:tsc": "tsc", - "build": "tsup", + "build": "npm run lint && tsup", "start": "node dist/index.js", "test": "node --experimental-vm-modules node_modules/jest/bin/jest.js", "bump": "node scripts/bumpVersion.js --bump && npm install", @@ -51,7 +52,7 @@ ] }, "dependencies": { - "@pinggy/pinggy": "^0.4.6", + "@pinggy/pinggy": "^0.4.8", "blessed": "^0.1.81", "clipboardy": "^5.0.0", "mime": "^4.1.0", @@ -68,7 +69,10 @@ "@types/qrcode": "^1.5.6", "@types/qrcode-terminal": "^0.12.2", "@types/ws": "^8.18.1", + "@typescript-eslint/eslint-plugin": "^8.60.0", + "@typescript-eslint/parser": "^8.60.0", "@yao-pkg/pkg": "^6.11.0", + "eslint": "^10.4.0", "jest": "^30.1.2", "ts-jest": "^29.4.1", "ts-node": "^10.9.2", @@ -80,4 +84,4 @@ "type": "git", "url": "git+https://github.com/Pinggy-io/cli-js.git" } -} +} \ No newline at end of file diff --git a/src/cli/buildAndStartTunnel.ts b/src/cli/buildAndStartTunnel.ts index 0032cb9..1f245f3 100644 --- a/src/cli/buildAndStartTunnel.ts +++ b/src/cli/buildAndStartTunnel.ts @@ -1,31 +1,93 @@ -import { TunnelManager } from "../tunnel_manager/TunnelManager.js"; import { logger } from "../logger.js"; -import { parseRemoteManagement } from "../remote_management/remoteManagement.js"; +import { + buildRemoteManagementWsUrl, + initiateRemoteManagement, + startRemoteManagement, +} from "../remote_management/remoteManagement.js"; import { ParsedValues } from "../utils/parseArgs.js"; import { cliOptions } from "./options.js"; -import { buildFinalConfig } from "./buildConfig.js"; -import { startCli } from "./starCli.js"; +import { buildFinalConfig, parseUsers } from "./buildConfig.js"; import CLIPrinter from "../utils/printer.js"; import { saveConfig, validateName, } from "./configStore.js"; +import { TunnelClient } from "../daemon/tunnelClient.js"; +import { startForegroundViaDaemon, startBackgroundViaDaemon } from "./startCli.js"; +import { daemonLostMessage } from "../utils/daemonLostMessage.js"; type CliValues = ParsedValues; +type Intent = + | { kind: "tunnel" } + | { kind: "remote-only" } + | { kind: "invalid"; message: string }; + +function analyzeIntent(values: CliValues, positionals: string[]): Intent { + const hasTunnelFlag = !!( + (Array.isArray(values.R) && values.R.length > 0) || + (Array.isArray(values.L) && values.L.length > 0) || + values.localport || + values.type || + values.conf || + values.serve + ); + + const parsed = parseUsers(positionals, values.token); + const hasServerFromArgs = !!parsed.server; + + if (hasTunnelFlag || hasServerFromArgs) return { kind: "tunnel" }; + + if (positionals.length > 0) { + return { + kind: "invalid", + message: + `Unrecognized argument(s): ${positionals.join(" ")}\n` + + `Usage: pinggy -l 3000 \n` + + `Try: pinggy --help`, + }; + } + + const rmToken = values["remote-management"]; + if (typeof rmToken === "string" && rmToken.trim().length > 0) { + return { kind: "remote-only" }; + } + + return { + kind: "invalid", + message: + "No tunnel specified.\n" + + "Usage: pinggy -l 3000 or pinggy @\n" + + "Try: pinggy --help", + }; +} + /** * Build config from CLI args, optionally save it, and start the tunnel. - * This is the default flow when no subcommand is used. */ export async function buildAndStartTunnel( values: CliValues, positionals: string[], - manager: TunnelManager ): Promise { - await initRemoteManagement(values); + const intent = analyzeIntent(values, positionals); + + if (intent.kind === "invalid") { + CLIPrinter.error(intent.message); + process.exit(1); + } + + if (intent.kind === "remote-only") { + CLIPrinter.print("Remote management mode. Press Ctrl+C to stop."); + await initRemoteManagement(values, /* blocking */ true); + return; + } + + // Tunnel intent. Start remote management in the background (if requested), + // then proceed to build and start the tunnel. + await initRemoteManagement(values, /* blocking */ false); logger.debug("Building final config from CLI values and positionals", { values, positionals }); - const finalConfig = await buildFinalConfig(values, positionals); + const finalConfig = buildFinalConfig(values, positionals); logger.debug("Final configuration built", finalConfig); if (values.save) { @@ -44,13 +106,42 @@ export async function buildAndStartTunnel( CLIPrinter.success(`Config "${name}" saved.`); } - await startCli(finalConfig, manager); + if (values.b) { + await startBackgroundViaDaemon(finalConfig); + return; + } + + await startForegroundViaDaemon(finalConfig); } -async function initRemoteManagement(values: CliValues): Promise { - const parseResult = await parseRemoteManagement(values); - if (parseResult?.ok === false) { - logger.error("Failed to initiate remote management:", parseResult.error); - CLIPrinter.fatal(parseResult.error); +async function initRemoteManagement(values: CliValues, blocking: boolean): Promise { + const rmToken = values["remote-management"]; + if (typeof rmToken !== "string" || rmToken.trim().length === 0) return; + + // Ensure daemon is running so remote management can route tunnel ops through it + const handler = await TunnelClient.forRemoteManagement(); + + // Exit code 3 mirrors EXIT_DAEMON_LOST used by the foreground tunnel path. + handler.onDaemonLost((reason, detail) => { + CLIPrinter.error(daemonLostMessage(reason, detail)); + setImmediate(() => process.exit(3)); + }); + + const config = { + apiKey: rmToken, + serverUrl: buildRemoteManagementWsUrl(values["manage"]), + }; + + try { + if (blocking) { + // Remote-only mode: stay attached until SIGINT. + await initiateRemoteManagement(config, handler); + } else { + // Returns after first connection; reconnect loop runs in the background. + await startRemoteManagement(config, handler); + } + } catch (e) { + logger.error("Failed to initiate remote management:", e); + CLIPrinter.fatal(e); } } diff --git a/src/cli/buildConfig.ts b/src/cli/buildConfig.ts index b4b22ed..2ebbb5f 100644 --- a/src/cli/buildConfig.ts +++ b/src/cli/buildConfig.ts @@ -526,7 +526,12 @@ function parseAutoReconnect(finalConfig: FinalConfig, values: ParsedValues, positionals: string[], baseConfig?: TunnelConfigurationV1): Promise { +function hasRemoteManagement(values: ParsedValues): boolean { + const token = values["remote-management"]; + return typeof token === "string" && token.trim().length > 0; +} + +export function buildFinalConfig(values: ParsedValues, positionals: string[], baseConfig?: TunnelConfigurationV1): FinalConfig { let token: string | undefined; let server: string | undefined; let type: string | undefined; @@ -549,14 +554,14 @@ export async function buildFinalConfig(values: ParsedValues, finalConfig = { ...defaultOptions, ...(configFromFile || {}), // Apply loaded config on top of defaults - configId: getRandomId(), + configId: configFromFile?.configId || getRandomId(), token: token || (configFromFile?.token || (typeof values.token === 'string' ? values.token : '')), serverAddress: server ? removeIPv6Brackets(server) : (configFromFile?.serverAddress || defaultOptions.serverAddress), isQRCode: qrCode || (configFromFile?.isQRCode || false), autoReconnect: configFromFile?.autoReconnect ? configFromFile.autoReconnect : defaultOptions.autoReconnect, optional: { serve: configFromFile?.optional?.serve || undefined, - noTui: values.noTui || values.notui || (configFromFile?.optional?.noTui || false), + noTui: values.noTui || values.notui || hasRemoteManagement(values) || (configFromFile?.optional?.noTui || false), }, }; diff --git a/src/cli/configStore.ts b/src/cli/configStore.ts index d3426bb..fd16e0c 100644 --- a/src/cli/configStore.ts +++ b/src/cli/configStore.ts @@ -29,6 +29,36 @@ export function sanitizeName(name: string): string { return name.replace(/[^a-zA-Z0-9_-]/g, "_"); } +export const Subcommand = { + Config: "config", + Start: "start", + Stop: "stop", + Ps: "ps", + Attach: "attach", + Daemon: "daemon", + DaemonAlias: "d", + Logs: "logs", + Log: "log", + Restart: "restart", +} as const; +export type Subcommand = typeof Subcommand[keyof typeof Subcommand]; + +export const ConfigVerb = { + List: "list", + Ls: "ls", + Show: "show", + Save: "save", + Update: "update", + Delete: "delete", + Auto: "auto", + Noauto: "noauto", +} as const; +export type ConfigVerb = typeof ConfigVerb[keyof typeof ConfigVerb]; + +export const SUBCOMMANDS = Object.values(Subcommand); +export const CONFIG_VERBS = Object.values(ConfigVerb); +export const RESERVED_NAMES = new Set([...SUBCOMMANDS, ...CONFIG_VERBS]); + /** * Validates that a tunnel name is acceptable. */ @@ -42,6 +72,9 @@ export function validateName(name: string): Error | null { if (!/^[a-zA-Z0-9_-]+$/.test(name)) { return new Error("Tunnel name can only contain alphanumeric characters, hyphens, and underscores."); } + if (RESERVED_NAMES.has(name.toLowerCase())) { + return new Error(`"${name}" is a reserved subcommand name. Use a different name.`); + } return null; } diff --git a/src/cli/defaults.ts b/src/cli/defaults.ts index 34c2e28..801743c 100644 --- a/src/cli/defaults.ts +++ b/src/cli/defaults.ts @@ -16,6 +16,6 @@ export const defaultOptions: Omit & { token: str httpsOnly: false, originalRequestUrl: false, allowPreflight: false, - reverseProxy: false, + reverseProxy: true, autoReconnect: true, }; diff --git a/src/cli/extendedOptions.ts b/src/cli/extendedOptions.ts index 456f92b..ae45d71 100644 --- a/src/cli/extendedOptions.ts +++ b/src/cli/extendedOptions.ts @@ -22,7 +22,7 @@ export function parseExtendedOptions(options: string[] | undefined, config: Tunn config.allowPreflight = true; break; - case "reverseproxy": + case "noreverseproxy": config.reverseProxy = false; break; diff --git a/src/cli/help.ts b/src/cli/help.ts index 4ebf3b0..af7eb53 100644 --- a/src/cli/help.ts +++ b/src/cli/help.ts @@ -1,16 +1,25 @@ import { cliOptions } from "./options.js"; +type CliOptionEntry = { + type: 'string' | 'boolean'; + description: string; + short?: string; + multiple?: boolean; + hidden?: boolean; +}; + export function printHelpMessage() { console.log("\nPinggy CLI Tool - Create secure tunnels to your localhost."); console.log("\nUsage:"); console.log(" pinggy [options] -l \n"); console.log("Options:"); - for (const [key, value] of Object.entries(cliOptions)) { - if ((value as any).hidden) continue; - const short = 'short' in value && (value as any).short ? `-${(value as any).short}, ` : ' '; - const optType = (value as any).type === 'boolean' ? '' : ''; - console.log(` ${short}--${key.padEnd(17)} ${optType.padEnd(8)} ${(value as any).description}`); + for (const [key, rawValue] of Object.entries(cliOptions)) { + const value = rawValue as CliOptionEntry; + if (value.hidden) continue; + const short = value.short ? `-${value.short}, ` : ' '; + const optType = value.type === 'boolean' ? '' : ''; + console.log(` ${short}--${key.padEnd(17)} ${optType.padEnd(8)} ${value.description}`); } console.log("\nExtended options :"); @@ -54,4 +63,22 @@ export function printHelpMessage() { console.log(" pinggy start my-tunnel -l 4000 # Start with runtime overrides"); console.log(" pinggy start tunnela tunnelb # Start multiple tunnels"); console.log(" pinggy start --all # Start all auto-start tunnels\n"); + + console.log("\nTunnel Management:"); + console.log(" pinggy ps # List running tunnels"); + console.log(" pinggy stop # Stop a running tunnel"); + console.log(" pinggy attach # Re-attach TUI to a running tunnel"); + console.log(" pinggy restart # Restart a running tunnel (picks up latest log level)"); + console.log(" pinggy logs [-f] [] # Show (or follow) tunnel or daemon logs"); + console.log(" pinggy log level [debug|info|error] # Get or set the log level"); + console.log(" pinggy log path [] # Print log file path"); + + console.log("\nBackground Mode:"); + console.log(" pinggy -l 3000 --b # Start tunnel in background"); + console.log(" pinggy start my-tunnel --b # Start saved tunnel in background"); + + console.log("\nDaemon Lifecycle (also: pinggy d ):"); + console.log(" pinggy daemon start # Start the background daemon"); + console.log(" pinggy daemon stop # Stop the daemon (stops all tunnels)"); + console.log(" pinggy daemon status # Show daemon PID and uptime"); } diff --git a/src/cli/options.ts b/src/cli/options.ts index 79a239b..57a64c5 100644 --- a/src/cli/options.ts +++ b/src/cli/options.ts @@ -20,6 +20,7 @@ export const cliOptions = { debugger: { type: 'string' as const, short: 'd', description: 'Port for web debugger. Eg. --debugger 4300 OR -d 4300' }, token: { type: 'string' as const, description: 'Token for authentication. Eg. --token TOKEN_VALUE' }, force: { type: 'boolean' as const, short: 'f', description: 'Forcefully close existing tunnels and establish a new tunnel' }, + follow: { type: 'boolean' as const, description: 'Follow log output (stream new lines as they appear)' }, // Logging options (CLI overrides env) loglevel: { type: 'string' as const, description: 'Logging level: ERROR, INFO, DEBUG. Overrides PINGGY_LOG_LEVEL environment variable' }, @@ -48,6 +49,13 @@ export const cliOptions = { manage: { type: 'string' as const, description: 'Provide a server address to manage tunnels. Eg --manage dashboard.pinggy.io' }, noTui: { type: 'boolean' as const, description: 'Disable TUI in remote management mode' }, notui: { type: 'boolean' as const, description: 'hidden', hidden: true }, + // Background mode (run tunnel in background via daemon) + b: { type: 'boolean' as const, description: 'Run tunnel in background via daemon. CLI exits after tunnel starts.' }, + all: { type: 'boolean' as const, description: 'Start all auto-start tunnels' }, + + // Internal daemon child marker + '_daemon-child': { type: 'boolean' as const, description: 'Internal: daemon child process marker', hidden: true }, + // Misc version: { type: 'boolean' as const, description: 'Print version' }, diff --git a/src/cli/starCli.ts b/src/cli/starCli.ts deleted file mode 100644 index 62e8b0e..0000000 --- a/src/cli/starCli.ts +++ /dev/null @@ -1,265 +0,0 @@ -import CLIPrinter from "../utils/printer.js"; -import { ManagedTunnel, TunnelManager } from "../tunnel_manager/TunnelManager.js"; -import { FinalConfig } from "../types.js"; -import { getFreePort } from "../utils/getFreePort.js"; -import { logger } from "../logger.js"; -import pico from "picocolors"; -import { TunnelTui } from "../tui/blessed/index.js" - -interface TunnelData { - urls: string[] | null; - greet: string | null; - usage: any; -} - -const TunnelData: TunnelData = { - urls: null, - greet: null, - usage: null, -}; - -let activeTui: any = null; // TunnelTui type - loaded dynamically - -let disconnectState: { - disconnected: boolean; - error?: string; - messages?: string[]; -} | null = null; - -declare global { - var __PINGGY_TUNNEL_STATS__: ((stats: any) => void) | undefined; -} - -async function launchTui(finalConfig: FinalConfig, urls: string[] | null, greet: string | null, tunnel: ManagedTunnel) { - try { - const isTTYEnabled = process.stdin.isTTY; - - if (!isTTYEnabled) { - CLIPrinter.warn("Unable to initiate the TUI: your terminal does not support the required input mode."); - return; - } - - - const tui = new TunnelTui({ - urls: urls ?? [], - greet: greet ?? "", - tunnelConfig: finalConfig, - disconnectInfo: null, - tunnelInstance: tunnel, - }); - - activeTui = tui; - - try { - tui.start(); - await tui.waitUntilExit(); - } catch (e) { - logger.warn("TUI error", e); - } finally { - activeTui = null; - } - } catch (e) { - logger.warn("Failed to (re-)initiate TUI", e); - } -} - - - -export async function startCli(finalConfig: FinalConfig, manager: TunnelManager) { - - - if (!finalConfig.optional?.noTui && finalConfig.webDebugger === "") { - // Need a webdebugger port - const freePort = await getFreePort(finalConfig.webDebugger || ""); - finalConfig.webDebugger = `localhost:${freePort}`; - } - - try { - const manager = TunnelManager.getInstance(); - const tunnel = await manager.createTunnel(finalConfig); - - - CLIPrinter.startSpinner("Connecting to Pinggy..."); - - - if (!finalConfig.optional?.noTui) { - manager.registerStatsListener(tunnel.tunnelid, (tunnelId, stats) => { - globalThis.__PINGGY_TUNNEL_STATS__?.(stats) - }) - } - - - manager.registerWorkerErrorListner(tunnel.tunnelid, (_tunnelid: string, error: Error) => { - - // The CLI terminates in this callback because these errors occur only when the tunnel worker - // exits, crashes, or encounters critical problems (e.g., authentication failure or primary forwarding failure). - - CLIPrinter.fatal(`${error.message}`); - }); - - - await manager.startTunnel(tunnel.tunnelid); - CLIPrinter.stopSpinnerSuccess(" Connected to Pinggy"); - CLIPrinter.success(pico.bold("Tunnel established!")); - CLIPrinter.print(pico.gray("───────────────────────────────")); - - TunnelData.urls = await manager.getTunnelUrls(tunnel.tunnelid); - TunnelData.greet = await manager.getTunnelGreetMessage(tunnel.tunnelid); - - CLIPrinter.info(pico.cyanBright("Remote URLs:")); - (TunnelData.urls ?? []).forEach((url: string) => - CLIPrinter.print(" " + pico.magentaBright(url)) - ); - CLIPrinter.print(pico.gray("───────────────────────────────")); - - - if (TunnelData.greet?.includes("not authenticated")) { - // show unauthenticated warning - CLIPrinter.warn(pico.yellowBright(TunnelData.greet)); - } else if (TunnelData.greet?.includes("authenticated as")) { - // extract email - const emailMatch = /authenticated as (.+)/.exec(TunnelData.greet); - if (emailMatch) { - const email = emailMatch[1]; - CLIPrinter.info(pico.cyanBright("Authenticated as: " + email)); - } - } - - CLIPrinter.print(pico.gray("───────────────────────────────")); - CLIPrinter.print(pico.gray("\nPress Ctrl+C to stop the tunnel.\n")); - - // Register reconnection event listeners for TUI modals - manager.registerWillReconnectListener(tunnel.tunnelid, (tunnelId, error, messages) => { - if (activeTui) { - const msg = messages?.join('\n') || error || 'Tunnel disconnected, reconnecting...'; - activeTui.updateReconnectingInfo(0, msg); - } else if (finalConfig.autoReconnect) { - CLIPrinter.warn(error || "Tunnel connection reset"); - CLIPrinter.startSpinner(messages?.join('\n')); - - } - }); - - manager.registerReconnectingListener(tunnel.tunnelid, (tunnelId, retryCnt) => { - if (activeTui) { - activeTui.updateReconnectingInfo(retryCnt); - } else if (finalConfig.autoReconnect) { - CLIPrinter.startSpinner(`Reconnecting to Pinggy (attempt #${retryCnt})`); - } - }); - - manager.registerReconnectionCompletedListener(tunnel.tunnelid, (tunnelId, urls) => { - if (activeTui) { - activeTui.closeReconnectingInfo(); - } - }); - - // On failed we are closing the TUI and showing the error in the CLI because if reconnection fails it means the tunnel is in bad state - manager.registerReconnectionFailedListener(tunnel.tunnelid, (tunnelId, retryCnt) => { - if (activeTui) { - activeTui.updateReconnectionFailed(retryCnt); - } else { - CLIPrinter.stopSpinnerFail(`Reconnection failed after ${retryCnt} attempts`); - process.exit(1); - } - }); - - manager.registerDisconnectListener(tunnel.tunnelid, async (tunnelId, error, messages) => { - if (activeTui) { - disconnectState = { - disconnected: true, - error: error, - messages: messages - }; - activeTui.updateDisconnectInfo(disconnectState); - - try { - // Wait for Blessed TUI to fully exit - await activeTui.waitUntilExit(); - } catch (e) { - logger.warn("Failed to wait for TUI exit", e); - } finally { - activeTui = null; - CLIPrinter.warn(`Error in tunnel:`); - messages?.forEach(function (m) { - CLIPrinter.warnTxt(m) - }); - - // Exit ONLY after blessed has restored the terminal - // On disconnect only exit if autoReconnect is false otherwise retry will not work - if (!finalConfig.autoReconnect) { - process.exit(0); - } - } - } else { - messages?.forEach(function (m) { - CLIPrinter.warn(m) - }); - - // On disconnect only exit if autoReconnect is false otherwise retry will not work - if (!finalConfig.autoReconnect) { - process.exit(0); - } - } - - // start a spinner if autoReconnect is true - if (finalConfig.autoReconnect) { - CLIPrinter.startSpinner("Reconnecting to Pinggy"); - } - }) - - // Listen for tunnel start events (auto-reconnect) - try { - await manager.registerStartListener(tunnel.tunnelid, async (tunnelId, urls) => { - try { - CLIPrinter.stopSpinnerSuccess("Reconnected to Pinggy"); - } catch (e) { - // ignore - } - - CLIPrinter.success(pico.bold("Tunnel re-established!")); - CLIPrinter.print(pico.gray("───────────────────────────────")); - - TunnelData.urls = urls; - TunnelData.greet = await manager.getTunnelGreetMessage(tunnel.tunnelid); - - CLIPrinter.info(pico.cyanBright("Remote URLs:")); - (TunnelData.urls ?? []).forEach((url: string) => - CLIPrinter.print(" " + pico.magentaBright(url)) - ); - CLIPrinter.print(pico.gray("───────────────────────────────")); - - if (TunnelData.greet?.includes("not authenticated")) { - CLIPrinter.warn(pico.yellowBright(TunnelData.greet)); - } else if (TunnelData.greet?.includes("authenticated as")) { - const emailMatch = /authenticated as (.+)/.exec(TunnelData.greet); - if (emailMatch) { - const email = emailMatch[1]; - CLIPrinter.info(pico.cyanBright("Authenticated as: " + email)); - } - } - - CLIPrinter.print(pico.gray("───────────────────────────────")); - CLIPrinter.print(pico.gray("\nPress Ctrl+C to stop the tunnel.\n")); - - // If the TUI was enabled previously, re-create and start it - if (!finalConfig.optional?.noTui) { - await launchTui(finalConfig, TunnelData.urls, TunnelData.greet, tunnel); - } - }); - } catch (e) { - logger.debug("Failed to register start listener", e); - } - - if (!finalConfig.optional?.noTui) { - await launchTui(finalConfig, TunnelData.urls, TunnelData.greet,tunnel); - } - - - - } catch (err: any) { - CLIPrinter.stopSpinnerFail("Failed to connect"); - CLIPrinter.fatal(err.message || "Unknown error"); - throw err; - } -} diff --git a/src/cli/startCli.ts b/src/cli/startCli.ts new file mode 100644 index 0000000..6ea47a1 --- /dev/null +++ b/src/cli/startCli.ts @@ -0,0 +1,556 @@ +import CLIPrinter from "../utils/printer.js"; +import { ErrorCode, FinalConfig, isErrorResponse } from "../types.js"; +import { getFreePort } from "../utils/getFreePort.js"; +import pico from "picocolors"; +import { TunnelClient, DaemonLostReason } from "../daemon/tunnelClient.js"; +import { SessionMode } from "../daemon/ipc/ipcRoutes.js"; +import { SavedTunnelConfig } from "./configStore.js"; +import { buildFinalConfig } from "./buildConfig.js"; +import { parseCliArgs } from "../utils/parseArgs.js"; +import { cliOptions } from "./options.js"; +import { TunnelResponseV2 } from "../remote_management/handler.js"; +import { daemonLostMessage } from "../utils/daemonLostMessage.js"; + +type CliValues = ReturnType>["values"]; + +// Exit code 3 is reserved for "daemon connection lost" so supervisors and +// scripts can distinguish it from normal failures (1) or user-initiated exit (0). +const EXIT_DAEMON_LOST = 3; + + +interface DaemonLostHandlers { + onReconnecting: (attempt: number, max: number) => void; + onReconnected: () => void; + onLost: (reason: DaemonLostReason, detail?: string) => void; +} + +/** + * Wire daemon-loss callbacks on the client. Caller provides UI-specific + * handlers (TUI modal updates, plain-stdout messages, etc). + */ +function wireDaemonLost(client: TunnelClient, handlers: DaemonLostHandlers): void { + client.onDaemonReconnecting(handlers.onReconnecting); + client.onDaemonReconnected(handlers.onReconnected); + client.onDaemonLost((reason, detail) => { + handlers.onLost(reason, detail); + CLIPrinter.error(daemonLostMessage(reason, detail)); + // TODO: print pinggy restart if it was a saved tunnel + setImmediate(() => process.exit(EXIT_DAEMON_LOST)); + }); +} + + +function installShutdownHandlers(handler: () => void | Promise): void { + let fired = false; + const wrapped = () => { + if (fired) return; + fired = true; + void handler(); + }; + process.on("SIGINT", wrapped); + process.on("SIGTERM", wrapped); + process.on("SIGHUP", wrapped); +} + +async function initTunnelClient(): Promise { + const client = new TunnelClient(); + CLIPrinter.startSpinner("Initializing..."); + try { + await client.ensureDaemon(); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + CLIPrinter.stopSpinnerFail(`Failed to start daemon: ${msg}`); + process.exit(1); + } + CLIPrinter.stopSpinnerSuccess("Initialized"); + return client; +} + +function printRemoteUrls(urls?: string[]): void { + for (const url of urls || []) { + CLIPrinter.print(" " + pico.magentaBright(url)); + } +} + +async function printAlreadyRunning( + client: TunnelClient, + configId: string | undefined, + label?: string, +): Promise { + const prefix = label ? `"${label}" ` : ""; + const list = await client.handleListV2(); + if (isErrorResponse(list)) { + CLIPrinter.warn(`${prefix}is already running, but could not fetch its state: ${list.message}`); + return null; + } + const tunnel = configId ? list.find(t => t.tunnelconfig?.configId === configId) : undefined; + if (!tunnel) { + CLIPrinter.warn(`${prefix}is already running, but it is no longer in the tunnel list.`); + return null; + } + const shortId = tunnel.tunnelid.slice(0, 8); + const name = tunnel.tunnelconfig?.name || label || shortId; + CLIPrinter.info(pico.cyanBright(`Tunnel "${name}" is already running.`)); + CLIPrinter.print(pico.gray("───────────────────────────────")); + CLIPrinter.print(` ID: ${pico.bold(shortId)}`); + CLIPrinter.print(` Status: ${tunnel.status?.state || "unknown"}`); + if (tunnel.remoteurls?.length) { + CLIPrinter.print(" URLs:"); + printRemoteUrls(tunnel.remoteurls); + } + CLIPrinter.print(pico.gray("───────────────────────────────")); + CLIPrinter.print(pico.gray(`Use 'pinggy attach ${name}' to view live output, or 'pinggy stop ${name}' to stop it.`)); + return tunnel; +} + +async function startTunnel( + client: TunnelClient, + config: FinalConfig, + opts: { label?: string; onError: "fatal"; mode: SessionMode }, +): Promise; +async function startTunnel( + client: TunnelClient, + config: FinalConfig, + opts: { label?: string; onError: "continue"; mode: SessionMode }, +): Promise; +async function startTunnel( + client: TunnelClient, + config: FinalConfig, + opts: { label?: string; onError: "fatal" | "continue"; mode: SessionMode }, +): Promise { + const result = await client.handleStartV2(config, false, opts.mode); + const prefix = opts.label ? `[${opts.label}] ` : ""; + + const fail = (reason: string): null => { + const msg = `${prefix}Failed to start tunnel: ${reason}`; + if (opts.onError === "fatal") { + CLIPrinter.error(msg); + client.close(); + process.exit(1); + } + CLIPrinter.error(msg); + return null; + }; + + if (isErrorResponse(result)) { + if (result.code === ErrorCode.TunnelAlreadyRunningError) { + await printAlreadyRunning(client, config.configId, opts.label); + return null; + } + return fail(result.message); + } + + // Tunnel may "start" but a fatal SDK-side error (e.g. polling) surfaced via status.lastError. + const lastError = (result as TunnelResponseV2).status?.lastError; + if (lastError?.isFatal) { + return fail(lastError.message); + } + + return result as TunnelResponseV2; +} + +async function waitForShutdownAndStopAll(client: TunnelClient, ids: string[]): Promise { + client.onDisconnect((id, error) => { + CLIPrinter.warn(`[${id.slice(0, 8)}] Disconnected: ${error}`); + }); + client.onReconnected((id, newUrls) => { + CLIPrinter.success(`[${id.slice(0, 8)}] Reconnected: ${newUrls.join(", ")}`); + }); + client.onReconnecting((id, retryCnt) => { + CLIPrinter.print(pico.gray(`[${id.slice(0, 8)}] Reconnecting (attempt #${retryCnt})...`)); + }); + client.onReconnectionFailed((id, retryCnt) => { + CLIPrinter.error(`[${id.slice(0, 8)}] Reconnection failed after ${retryCnt} attempts`); + }); + + wireDaemonLost(client, { + onReconnecting: (attempt, max) => CLIPrinter.warn(`Daemon connection dropped — reconnecting (${attempt}/${max})...`), + onReconnected: () => CLIPrinter.success("Daemon reconnected."), + onLost: () => { /* shared printer + exit handled by wireDaemonLost */ }, + }); + + await new Promise((resolve) => { + installShutdownHandlers(async () => { + CLIPrinter.print("\nStopping all tunnels..."); + if (!client.isDaemonLost()) { + for (const id of ids) { + try { await client.handleStop(id); } catch { /* daemon may have died mid-shutdown */ } + } + } + client.close(); + resolve(); + }); + }); +} + +// Tunnel lookup + +/** + * Resolve a tunnel by name, ID, short ID prefix, or configId. + * Priority: exact tunnelid > tunnelid prefix > name > configId. + */ +export function findTunnel(tunnels: TunnelResponseV2[], nameOrId: string): TunnelResponseV2 | null { + let byShortId: TunnelResponseV2 | undefined; + let byName: TunnelResponseV2 | undefined; + let byConfigId: TunnelResponseV2 | undefined; + + for (const t of tunnels) { + if (t.tunnelid === nameOrId) return t; + if (!byShortId && t.tunnelid.startsWith(nameOrId)) byShortId = t; + if (!byName && t.tunnelconfig?.name === nameOrId) byName = t; + if (!byConfigId && t.tunnelconfig?.configId === nameOrId) byConfigId = t; + } + + return byShortId ?? byName ?? byConfigId ?? null; +} + +// Shared TUI / non-TUI event wiring + +export interface ConnectTuiOptions { + client: TunnelClient; + tunnelId: string; + urls: string[]; + greet: string; + tunnelConfig: FinalConfig; + /** Called when TUI exits or Ctrl+C in non-TUI mode */ + onExit: () => Promise; + /** Called on SIGINT in non-TUI mode (before onExit). Defaults to "Stopping tunnel..." */ + exitMessage?: string; + /** If true, skip TUI even if TTY is available */ + noTui?: boolean; +} + +/** + * Wire TUI (or non-TUI fallback) to a TunnelClient's event stream. + * Shared between foreground start and attach commands. + */ +export async function connectTui(opts: ConnectTuiOptions): Promise { + const { client, tunnelId, urls, greet, tunnelConfig, onExit, noTui } = opts; + const exitMessage = opts.exitMessage || "Stopping tunnel..."; + + if (!noTui && process.stdin.isTTY) { + try { + const { TunnelTui } = await import("../tui/blessed/TunnelTui.js"); + + const tui = new TunnelTui({ + urls, + greet, + tunnelConfig, + // Skip the user-provided onStop if the daemon is already gone: + onStop: async () => { + if (client.isDaemonLost()) return; + await onExit(); + }, + }); + + client.onStats((id, stats) => { + if (id === tunnelId) tui.updateStats(stats); + }); + + client.onDisconnect((id, error, messages) => { + if (id === tunnelId) tui.showDisconnectModal(error, messages); + }); + + client.onReconnecting((id, retryCnt) => { + if (id === tunnelId) tui.updateReconnectingInfo(retryCnt); + }); + + client.onReconnected((id, newUrls) => { + if (id === tunnelId) { + tui.closeReconnectingInfo(); + tui.updateUrls(newUrls); + } + }); + + client.onReconnectionFailed((id, retryCnt) => { + if (id === tunnelId) tui.updateReconnectionFailed(retryCnt); + }); + + client.onStopped((id) => { + if (id === tunnelId) tui.stop(); + }); + + wireDaemonLost(client, { + onReconnecting: (attempt, max) => tui.updateReconnectingInfo(attempt, `Daemon disconnected — reconnecting (${attempt}/${max})...`), + onReconnected: () => tui.closeReconnectingInfo(), + onLost: () => tui.stop(), + }); + + tui.start(); + await tui.waitUntilExit(); + // Tunnel stop is handled inside tui.destroy() via onStop, so no + // explicit onExit() call here + } catch { + // TUI unavailable, fall through to non-TUI path + } + } else { + client.onStats((id, stats) => { + if (id === tunnelId) { + process.stdout.write(`\r${pico.gray(`Connections: ${stats.numTotalConnections} | Bytes: ${stats.numTotalTxBytes}`)}`); + } + }); + + client.onDisconnect((id, error) => { + if (id === tunnelId) CLIPrinter.warn(`Disconnected: ${error}`); + }); + + client.onReconnected((id, newUrls) => { + if (id === tunnelId) CLIPrinter.success(`Reconnected: ${newUrls.join(", ")}`); + }); + + client.onStopped((id) => { + if (id === tunnelId) { + CLIPrinter.print("\nTunnel stopped."); + process.exit(0); + } + }); + + wireDaemonLost(client, { + onReconnecting: (attempt, max) => CLIPrinter.warn(`\nDaemon connection dropped — reconnecting (${attempt}/${max})...`), + onReconnected: () => CLIPrinter.success("Daemon reconnected."), + onLost: () => { /* shared printer + exit handled by wireDaemonLost */ }, + }); + + await new Promise((resolve) => { + installShutdownHandlers(async () => { + CLIPrinter.print(`\n${exitMessage}`); + if (!client.isDaemonLost()) { + try { await onExit(); } catch { /* daemon may be dead */ } + } + client.close(); + resolve(); + }); + }); + } +} + +// Single foreground tunnel + +export async function startForegroundViaDaemon(finalConfig: FinalConfig): Promise { + if (!finalConfig.optional?.noTui && finalConfig.webDebugger === "") { + const freePort = await getFreePort(finalConfig.webDebugger || ""); + finalConfig.webDebugger = `localhost:${freePort}`; + } + + const client = await initTunnelClient(); + + CLIPrinter.startSpinner("Submitting tunnel to daemon..."); + const pending = await client.handleStartV2(finalConfig, true, SessionMode.Foreground); + + if (isErrorResponse(pending)) { + if (pending.code === ErrorCode.TunnelAlreadyRunningError) { + CLIPrinter.stopSpinnerSuccess("Already running"); + await printAlreadyRunning(client, finalConfig.configId, finalConfig.name); + client.close(); + process.exit(0); + } + CLIPrinter.stopSpinnerFail("Failed to start"); + CLIPrinter.error(`Failed to start tunnel: ${pending.message}`); + client.close(); + process.exit(1); + } + + const tunnelId = pending.tunnelid; + CLIPrinter.startSpinner(`Tunnel ${tunnelId.slice(0, 8)} created — ${pending.status?.state || "starting"}...`); + + await client.attach(tunnelId, "foreground"); + CLIPrinter.startSpinner(`Tunnel ${tunnelId.slice(0, 8)} — waiting for connection...`); + + const outcome = await waitForTunnelLive(client, tunnelId); + if ("error" in outcome) { + CLIPrinter.stopSpinnerFail("Failed to connect"); + CLIPrinter.error(`Failed to start tunnel: ${outcome.error}`); + client.close(); + process.exit(1); + } + + CLIPrinter.stopSpinnerSuccess(" Connected to Pinggy"); + + const tunnel = await fetchTunnelV2(client, tunnelId); + const urls: string[] = outcome.urls.length ? outcome.urls : (tunnel?.remoteurls || []); + const greetmsg = tunnel?.greetmsg || ""; + + CLIPrinter.success(pico.bold("Tunnel established!")); + CLIPrinter.print(pico.gray("───────────────────────────────")); + CLIPrinter.info(pico.cyanBright("Remote URLs:")); + printRemoteUrls(urls); + CLIPrinter.print(pico.gray("───────────────────────────────")); + + if (greetmsg.includes("not authenticated")) { + CLIPrinter.warn(pico.yellowBright(greetmsg)); + } else if (greetmsg.includes("authenticated as")) { + const emailMatch = /authenticated as (.+)/.exec(greetmsg); + if (emailMatch) { + CLIPrinter.info(pico.cyanBright("Authenticated as: " + emailMatch[1])); + } + } + + CLIPrinter.print(pico.gray("───────────────────────────────")); + CLIPrinter.print(pico.gray("\nPress Ctrl+C to stop the tunnel.\n")); + + await connectTui({ + client, + tunnelId, + urls, + greet: greetmsg, + tunnelConfig: finalConfig, + noTui: !!finalConfig.optional?.noTui, + onExit: async () => { + await client.handleStop(tunnelId); + }, + }); + + client.close(); +} + +/** + * Resolve when the tunnel reports URLs (live) or surfaces a fatal error. + */ +async function waitForTunnelLive( + client: TunnelClient, + tunnelId: string, +): Promise<{ urls: string[] } | { error: string }> { + return new Promise((resolve) => { + let settled = false; + const done = (val: { urls: string[] } | { error: string }) => { + if (settled) return; + settled = true; + resolve(val); + }; + + client.onUrlReady((id, urls) => { if (id === tunnelId) done({ urls }); }); + client.onError((id, message, isFatal) => { if (id === tunnelId && isFatal) done({ error: message }); }); + client.onDisconnect((id, error) => { if (id === tunnelId) done({ error }); }); + + // Race guard: if startTunnel() finished before our subscribe registered, + // the url_ready event already fired. Catch up via a one-shot fetch. + client.handleListV2().then((res) => { + if (settled || isErrorResponse(res)) return; + const t = res.find((x) => x.tunnelid === tunnelId); + if (t?.remoteurls?.length) done({ urls: t.remoteurls }); + if (t?.status?.lastError?.isFatal) done({ error: t.status.lastError.message }); + }).catch(() => { /* ignore — events will resolve */ }); + }); +} + +async function fetchTunnelV2(client: TunnelClient, tunnelId: string): Promise { + const res = await client.handleListV2(); + if (isErrorResponse(res)) return null; + return res.find((t) => t.tunnelid === tunnelId) || null; +} + +// Single background tunnel + +export async function startBackgroundViaDaemon(finalConfig: FinalConfig): Promise { + const client = await initTunnelClient(); + + CLIPrinter.info("Starting tunnel..."); + const result = await startTunnel(client, finalConfig, { onError: "fatal", mode: SessionMode.Detached }); + + const tunnelId = result.tunnelid; + CLIPrinter.success(`Tunnel started (ID: ${tunnelId})`); + printRemoteUrls(result.remoteurls); + CLIPrinter.print(pico.gray("\nTunnel running in background. Use 'pinggy ps' to list, 'pinggy stop " + tunnelId.slice(0, 8) + "' to stop.")); + client.close(); +} + +// Multiple foreground tunnels + +export async function startMultipleForegroundViaDaemon( + configs: SavedTunnelConfig[], + values: CliValues, + positionals: string[] +): Promise { + const client = await initTunnelClient(); + const startedIds: string[] = []; + + CLIPrinter.print(pico.cyanBright(`Starting ${configs.length} tunnel(s)...`)); + for (const saved of configs) { + const config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name }; + const result = await startTunnel(client, config, { label: saved.name, onError: "continue", mode: SessionMode.Foreground }); + if (!result) continue; + + startedIds.push(result.tunnelid); + CLIPrinter.success(`"${saved.name}" started`); + printRemoteUrls(result.remoteurls); + } + + if (startedIds.length === 0) { + CLIPrinter.error("No tunnels started."); + client.close(); + return; + } + + for (const id of startedIds) { + await client.attach(id, "foreground"); + } + + CLIPrinter.print(pico.gray("\nAll tunnels launched. Press Ctrl+C to stop.\n")); + await waitForShutdownAndStopAll(client, startedIds); +} + +// Background tunnels + +export async function startBackgroundTunnels( + configs: SavedTunnelConfig[], + values: CliValues, + positionals: string[] +): Promise { + const client = await initTunnelClient(); + + const buildConfig = configs.length === 1 + ? (saved: SavedTunnelConfig) => buildFinalConfig(values, positionals, saved.tunnelConfig) + : (saved: SavedTunnelConfig) => + ({ ...saved.tunnelConfig, configId: saved.configId, name: saved.name } as FinalConfig); + + for (const saved of configs) { + const finalConfig = buildConfig(saved); + + const result = await startTunnel(client, finalConfig, { label: saved.name, onError: "continue", mode: SessionMode.Detached }); + if (!result) continue; + + CLIPrinter.success(`"${saved.name}" started (ID: ${result.tunnelid})`); + printRemoteUrls(result.remoteurls); + } + + CLIPrinter.print(pico.gray("\nTunnel(s) running in background. Use 'pinggy ps' to list, 'pinggy stop ' to stop.")); + client.close(); +} + +// Auto-start tunnels + +export async function startAutoStartTunnels(): Promise { + const { getAutoStartConfigs } = await import("./configStore.js"); + const configs = getAutoStartConfigs(); + if (configs.length === 0) { + CLIPrinter.warn("No configs marked for auto-start. Use: pinggy config auto "); + return; + } + + const client = await initTunnelClient(); + const startedIds: string[] = []; + + CLIPrinter.print(pico.cyanBright(`Starting ${configs.length} auto-start tunnel(s)...`)); + for (const saved of configs) { + const config = { ...saved.tunnelConfig, configId: saved.configId, name: saved.name }; + const result = await startTunnel(client, config, { label: saved.name, onError: "continue", mode: SessionMode.Foreground }); + if (!result) continue; + + startedIds.push(result.tunnelid); + CLIPrinter.success(`"${saved.name}" started`); + printRemoteUrls(result.remoteurls); + } + + if (startedIds.length === 0) { + CLIPrinter.error("No tunnels started."); + client.close(); + return; + } + + for (const id of startedIds) { + await client.attach(id, "foreground"); + } + + CLIPrinter.print(pico.gray("\nAll auto-start tunnels launched. Press Ctrl+C to stop.\n")); + await waitForShutdownAndStopAll(client, startedIds); +} diff --git a/src/cli/subcommand/handlers/attachCommand.ts b/src/cli/subcommand/handlers/attachCommand.ts new file mode 100644 index 0000000..0d7f686 --- /dev/null +++ b/src/cli/subcommand/handlers/attachCommand.ts @@ -0,0 +1,79 @@ +/** + * `pinggy attach ` — Re-attach TUI to a running daemon tunnel. + */ + +import pico from "picocolors"; +import CLIPrinter from "../../../utils/printer.js"; +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import { isErrorResponse } from "../../../types.js"; +import { connectTui, findTunnel } from "../../startCli.js"; +import { TunnelConfigV1 } from "../../../remote_management/remote_schema.js"; +import { errorMessage } from "../../../utils/util.js"; + + +export async function handleAttach(args: string[]): Promise { + if (args.length === 0) { + CLIPrinter.error("Usage: pinggy attach "); + return; + } + + const nameOrId = args[0]; + const client = new TunnelClient(); + + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + const tunnels = await client.handleListV2(); + + if (isErrorResponse(tunnels)) { + CLIPrinter.error(`Failed to list tunnels: ${tunnels.message}`); + return; + } + + const match = findTunnel(tunnels, nameOrId); + if (!match) { + CLIPrinter.error(`No running tunnel found matching "${nameOrId}". Use: pinggy ps`); + client.close(); + return; + } + + const tunnelId = match.tunnelid; + const name = (match.tunnelconfig as TunnelConfigV1)?.name || tunnelId.slice(0, 12); + + CLIPrinter.print(pico.cyanBright(`Attaching to tunnel "${name}"...`)); + + // Preserve the tunnel's existing lifecycle mode so attach is a pure + // viewing operation. + const attachMode = match.mode ?? "detached"; + await client.attach(tunnelId, attachMode); + + const urls: string[] = match.remoteurls || []; + if (urls.length > 0) { + CLIPrinter.print(""); + for (const url of urls) { + CLIPrinter.print(" " + pico.magentaBright(url)); + } + CLIPrinter.print(""); + } + + await connectTui({ + client, + tunnelId, + urls, + greet: match.greetmsg || "", + tunnelConfig: match.tunnelconfig || {}, + exitMessage: "Detaching...", + onExit: () => Promise.resolve(client.detach(tunnelId)), + }); + } catch (err) { + CLIPrinter.error(`Failed to attach: ${errorMessage(err)}`); + } finally { + client.close(); + } +} + diff --git a/src/cli/subcommand/handlers/daemonCommandsHandler.ts b/src/cli/subcommand/handlers/daemonCommandsHandler.ts new file mode 100644 index 0000000..43e2df1 --- /dev/null +++ b/src/cli/subcommand/handlers/daemonCommandsHandler.ts @@ -0,0 +1,119 @@ +/** + * Daemon subcommand handlers (lifecycle only). + * + * Accessible via `pinggy daemon ` or `pinggy d `. + * + * Tunnel operations (ps, stop ) have moved to top-level commands: + * pinggy ps → list running tunnels + * pinggy stop → stop a specific tunnel + */ +import CLIPrinter from "../../../utils/printer.js"; +import pico from "picocolors"; +import { getAutoStartConfigs } from "../../configStore.js"; +import { startDaemon, stopDaemon, getDaemonInfo, isDaemonRunning } from "../../../daemon/lifecycle/daemonManager.js"; +import { installService, uninstallService } from "../../../daemon/lifecycle/serviceInstaller.js"; +import { printDaemonHelp } from "../../../utils/helpMessages.js"; +import { errorMessage } from "../../../utils/util.js"; + +// Daemon command router + +export async function handleDaemon(args: string[]): Promise { + if (args.length === 0) { + printDaemonHelp(); + return; + } + + const verb = args[0]; + + switch (verb) { + case "start": + await handleDaemonStart(); + return; + + case "stop": + await handleDaemonStop(); + return; + + case "status": + handleDaemonStatus(); + return; + + case "service-install": + installService(); + return; + + case "service-uninstall": + uninstallService(); + return; + + default: + CLIPrinter.error(`Unknown daemon command: "${verb}"`); + printDaemonHelp(); + return; + } +} + + +async function handleDaemonStart(): Promise { + if (isDaemonRunning()) { + const info = getDaemonInfo(); + CLIPrinter.print(pico.yellow(`Daemon already running (PID ${info?.pid}, port ${info?.port}). Use: pinggy daemon status for details.`)); + return; + } + + // Show which tunnels will auto-start + const autoConfigs = getAutoStartConfigs(); + if (autoConfigs.length > 0) { + CLIPrinter.print(pico.cyanBright(`Starting daemon with ${autoConfigs.length} auto-start tunnel(s):`)); + for (const c of autoConfigs) { + CLIPrinter.print(` ${pico.bold(c.name)} (${c.configId.slice(0, 8)})`); + } + } else { + CLIPrinter.print(pico.cyanBright("Starting daemon...")); + } + + try { + const info = await startDaemon(); + CLIPrinter.success(`Daemon started PID ${info.pid}.`); + } catch (err) { + CLIPrinter.error(`Failed to start daemon: ${errorMessage(err)}`); + process.exit(1); + } +} + +async function handleDaemonStop(): Promise { + if (!isDaemonRunning()) { + CLIPrinter.print(pico.yellow("No daemon is running.")); + return; + } + + const result = await stopDaemon(); + if (result.ok) { + CLIPrinter.success("Daemon stopped."); + } else { + CLIPrinter.error(result.error); + } +} + +function handleDaemonStatus(): void { + const info = getDaemonInfo(); + if (!info) { + CLIPrinter.print(pico.yellow("No daemon is running. Start with: pinggy daemon start")); + return; + } + + const uptimeSec = Math.floor((Date.now() - new Date(info.startedAt).getTime()) / 1000); + const h = Math.floor(uptimeSec / 3600); + const m = Math.floor((uptimeSec % 3600) / 60); + const s = uptimeSec % 60; + const uptimeStr = h > 0 ? `${h}h ${m}m ${s}s` : m > 0 ? `${m}m ${s}s` : `${s}s`; + + CLIPrinter.print(pico.cyanBright("Daemon Status")); + CLIPrinter.print(` PID: ${info.pid}`); + CLIPrinter.print(` Port: ${info.port}`); + CLIPrinter.print(` Started: ${info.startedAt}`); + CLIPrinter.print(` Uptime: ${uptimeStr}`); +} + + + diff --git a/src/cli/subcommand/handlers/logCommand.ts b/src/cli/subcommand/handlers/logCommand.ts new file mode 100644 index 0000000..dd42c64 --- /dev/null +++ b/src/cli/subcommand/handlers/logCommand.ts @@ -0,0 +1,109 @@ +/** + * `pinggy log level [debug|info|error]` - Get or set daemon log level. + * `pinggy log path [name|id]` - Print daemon or tunnel log path. + */ +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import { printLogHelp } from "../../../utils/helpMessages.js"; +import CLIPrinter from "../../../utils/printer.js"; +import { errorMessage } from "../../../utils/util.js"; + +const VALID_LEVELS = ["debug", "info", "error"] as const; +type LogLevel = (typeof VALID_LEVELS)[number]; + +export async function handleLog(args: string[]): Promise { + if (args.length === 0) { + printLogHelp(); + return; + } + + const verb = args[0]; + const rest = args.slice(1); + + switch (verb) { + case "level": + await handleLogLevel(rest); + break; + case "path": + await handleLogPath(rest); + break; + default: + CLIPrinter.error(`Unknown log subcommand: ${verb}`); + printLogHelp(); + } +} + +async function handleLogLevel(args: string[]): Promise { + const client = new TunnelClient(); + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + if (args.length === 0) { + const level = await client.getLogLevel(); + CLIPrinter.print(`Log level: ${level}`); + return; + } + + const level = args[0].toLowerCase(); + if (!VALID_LEVELS.includes(level as LogLevel)) { + CLIPrinter.error(`Invalid log level: "${level}". Must be one of: ${VALID_LEVELS.join(", ")}`); + client.close(); + process.exit(1); + } + + await client.setLogLevel(level as LogLevel); + CLIPrinter.success(`Log level set to "${level}". Affects daemon JS logs and new tunnels. Run \`pinggy restart \` to apply to a running tunnel.`); + } catch (err) { + CLIPrinter.error(`Failed to update log level: ${errorMessage(err)}`); + } finally { + client.close(); + } +} + +async function handleLogPath(args: string[]): Promise { + const client = new TunnelClient(); + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + if (args.length === 0) { + const paths = await client.getLogPaths(); + CLIPrinter.print(paths.daemon); + return; + } + + const arg = args[0]; + const result = await client.resolveLogPath(arg); + + switch (result.status) { + case "running": + case "historical": + CLIPrinter.print(result.path!); + break; + case "config-only": + CLIPrinter.error(`Tunnel "${arg}" is saved but has not been started yet. No logs available.`); + client.close(); + process.exit(1); + break; + case "not-found": + CLIPrinter.error(`No tunnel or log file matching "${arg}".`); + client.close(); + process.exit(1); + break; + } + } catch (err) { + CLIPrinter.error(`Failed to resolve log path: ${errorMessage(err)}`); + } finally { + client.close(); + } +} + + diff --git a/src/cli/subcommand/handlers/logsCommand.ts b/src/cli/subcommand/handlers/logsCommand.ts new file mode 100644 index 0000000..ded1dcb --- /dev/null +++ b/src/cli/subcommand/handlers/logsCommand.ts @@ -0,0 +1,105 @@ +/** + * `pinggy logs [name|id] [-f]` - Print or follow a tunnel or daemon log file. + */ +import fs from "node:fs"; +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import CLIPrinter from "../../../utils/printer.js"; +import { errorMessage } from "../../../utils/util.js"; + +export async function handleLogs(args: string[], follow: boolean): Promise { + const arg = args.find((a) => !a.startsWith("-")); + + const client = new TunnelClient(); + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + let filePath: string; + + try { + if (arg) { + const result = await client.resolveLogPath(arg); + if (result.status === "config-only") { + CLIPrinter.error(`Tunnel "${arg}" is saved but has not been started yet. No logs available.`); + client.close(); + process.exit(1); + } + if (result.status === "not-found") { + CLIPrinter.error(`No tunnel or log file matching "${arg}".`); + client.close(); + process.exit(1); + } + if (!result.path) { + CLIPrinter.error(`No log path returned for "${arg}".`); + client.close(); + process.exit(1); + } + filePath = result.path; + } else { + const paths = await client.getLogPaths(); + filePath = paths.daemon; + } + } catch (err) { + CLIPrinter.error(`Failed to resolve log path: ${errorMessage(err)}`); + client.close(); + return; + } + + client.close(); + + if (!filePath || !fs.existsSync(filePath)) { + CLIPrinter.error(`Log file not found: ${filePath}`); + process.exit(1); + } + + if (follow) { + await followFile(filePath); + } else { + printTail(filePath, 100); + } +} + +function printTail(filePath: string, lines: number): void { + const content = fs.readFileSync(filePath, "utf-8"); + const allLines = content.split("\n"); + const tail = allLines.slice(-lines).join("\n"); + process.stdout.write(tail); + if (!tail.endsWith("\n")) process.stdout.write("\n"); +} + +async function followFile(filePath: string): Promise { + if (fs.existsSync(filePath)) printTail(filePath, 20); + + let fileSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0; + let watcher: fs.FSWatcher | null = null; + + const openStream = () => { + const stream = fs.createReadStream(filePath, { start: fileSize, encoding: "utf-8" }); + stream.on("data", (chunk) => { + process.stdout.write(chunk as string); + fileSize += Buffer.byteLength(chunk as string, "utf-8"); + }); + stream.on("error", () => {}); // file may rotate + }; + + watcher = fs.watch(filePath, { persistent: true }, (event) => { + if (event === "change") { + const stat = fs.existsSync(filePath) ? fs.statSync(filePath) : null; + if (!stat || stat.size < fileSize) { + // Rotation: reopen from start + fileSize = 0; + } + openStream(); + } + }); + + await new Promise((resolve) => { + process.on("SIGINT", () => { + watcher?.close(); + resolve(); + }); + }); +} diff --git a/src/cli/subcommand/handlers/psCommand.ts b/src/cli/subcommand/handlers/psCommand.ts new file mode 100644 index 0000000..6f761b8 --- /dev/null +++ b/src/cli/subcommand/handlers/psCommand.ts @@ -0,0 +1,61 @@ +/** + * `pinggy ps` — List all running tunnels in the daemon. + */ +import { TunnelConfigurationV1 } from "@pinggy/pinggy"; +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import { isErrorResponse } from "../../../types.js"; +import CLIPrinter from "../../../utils/printer.js"; +import { errorMessage, getLocalAddress } from "../../../utils/util.js"; +import pico from "picocolors"; + +export async function handlePs(): Promise { + const client = new TunnelClient(); + + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + const tunnels = await client.handleListV2(); + + if (isErrorResponse(tunnels)) { + CLIPrinter.error(`Failed to list tunnels: ${tunnels.message}`); + return; + } + + if (tunnels.length === 0) { + CLIPrinter.print("No tunnels running."); + return; + } + + // Print table header + const header = `${pad("ID", 14)} ${pad("NAME", 16)} ${pad("STATUS", 10)} ${pad("LOCAL", 20)} URL`; + CLIPrinter.print(pico.gray(header)); + CLIPrinter.print(pico.gray("─".repeat(90))); + + for (const t of tunnels) { + const id = t.tunnelid.slice(0, 12); + const name = (t.tunnelconfig as TunnelConfigurationV1)?.name || "-"; + const status = t.status.state; + const local = getLocalAddress(t.tunnelconfig); + const url = t.remoteurls?.[0] || "-"; + + const statusColor = status === "running" ? pico.green : status === "starting" ? pico.yellow : pico.red; + + CLIPrinter.print( + `${pico.cyan(pad(id, 14))} ${pad(name, 16)} ${statusColor(pad(status, 10))} ${pad(local, 20)} ${pico.magentaBright(url)}` + ); + } + } catch (err) { + CLIPrinter.error(`Failed to list tunnels: ${errorMessage(err)}`); + } finally { + client.close(); + } +} + +function pad(str: string, len: number): string { + return str.length >= len ? str.slice(0, len) : str + " ".repeat(len - str.length); +} diff --git a/src/cli/subcommand/handlers/restartCommand.ts b/src/cli/subcommand/handlers/restartCommand.ts new file mode 100644 index 0000000..113076a --- /dev/null +++ b/src/cli/subcommand/handlers/restartCommand.ts @@ -0,0 +1,52 @@ +/** + * `pinggy restart ` — Restart a running tunnel. + */ +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import { isErrorResponse } from "../../../types.js"; +import CLIPrinter from "../../../utils/printer.js"; +import { errorMessage } from "../../../utils/util.js"; +import { findTunnel } from "../../startCli.js"; + +export async function handleRestart(args: string[]): Promise { + if (args.length === 0 || args[0].startsWith("-")) { + CLIPrinter.error("Tunnel name or ID is required. Usage: pinggy restart "); + process.exit(1); + } + + const nameOrId = args[0]; + const client = new TunnelClient(); + + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + const tunnels = await client.handleListV2(); + if (isErrorResponse(tunnels)) { + CLIPrinter.error(`Failed to list tunnels: ${tunnels.message}`); + return; + } + + const match = findTunnel(tunnels, nameOrId); + if (!match) { + CLIPrinter.error(`No running tunnel matching "${nameOrId}". Use: pinggy ps`); + process.exit(1); + } + + const result = await client.handleRestart(match.tunnelid); + if (isErrorResponse(result)) { + CLIPrinter.error(`Failed to restart tunnel: ${result.message}`); + return; + } + + const name = match.tunnelconfig.name || match.tunnelid.slice(0, 12); + CLIPrinter.success(`Tunnel "${name}" is restarting.`); + } catch (err) { + CLIPrinter.error(`Failed to restart tunnel: ${errorMessage(err)}`); + } finally { + client.close(); + } +} diff --git a/src/cli/subcommand/handlers/stopCommand.ts b/src/cli/subcommand/handlers/stopCommand.ts new file mode 100644 index 0000000..6395834 --- /dev/null +++ b/src/cli/subcommand/handlers/stopCommand.ts @@ -0,0 +1,83 @@ +/** + * `pinggy stop [ ...]` — Stop one or more tunnels. + * + */ +import { TunnelClient } from "../../../daemon/tunnelClient.js"; +import { isErrorResponse } from "../../../types.js"; +import { TunnelResponseV2 } from "../../../remote_management/handler.js"; +import CLIPrinter from "../../../utils/printer.js"; +import { errorMessage } from "../../../utils/util.js"; +import { findTunnel } from "../../startCli.js"; + +export async function handleStop(args: string[]): Promise { + if (args.length === 0) { + CLIPrinter.error("Usage: pinggy stop [ ...]"); + return; + } + + const client = new TunnelClient(); + + try { + await client.ensureDaemon(); + } catch (err) { + CLIPrinter.error(`Cannot connect to daemon: ${errorMessage(err)}`); + return; + } + + try { + const tunnels = await client.handleListV2(); + if (isErrorResponse(tunnels)) { + CLIPrinter.error(`Failed to list tunnels: ${tunnels.message}`); + return; + } + + const targets = resolveTargets(tunnels, args); + + if (targets.matched.length === 0) { + CLIPrinter.error(`No tunnel found matching: ${args.join(", ")}. Use: pinggy ps`); + return; + } + + for (const { input, tunnel } of targets.matched) { + const result = await client.handleStop(tunnel.tunnelid); + if (isErrorResponse(result)) { + CLIPrinter.error(`Failed to stop "${input}": ${result.message}`); + continue; + } + const name = tunnel.tunnelconfig.name || tunnel.tunnelid.slice(0, 12); + CLIPrinter.success(`Tunnel "${name}" stopped.`); + } + + for (const m of targets.missing) { + CLIPrinter.warn(`No tunnel found matching "${m}".`); + } + } catch (err) { + CLIPrinter.error(`Failed to stop tunnel: ${errorMessage(err)}`); + } finally { + client.close(); + } +} + +interface ResolvedTargets { + matched: { input: string; tunnel: TunnelResponseV2 }[]; + missing: string[]; +} + +function resolveTargets(tunnels: TunnelResponseV2[], args: string[]): ResolvedTargets { + // If args could form a single name with spaces (e.g. `stop tls tunnel` + if (args.length > 1) { + const input = args.join(" "); + const tunnel = findTunnel(tunnels, input); + if (tunnel) return { matched: [{ input, tunnel }], missing: [] }; + } + + const matched: { input: string; tunnel: TunnelResponseV2 }[] = []; + const missing: string[] = []; + for (const input of args) { + const tunnel = findTunnel(tunnels, input); + if (tunnel) matched.push({ input, tunnel }); + else missing.push(input); + } + return { matched, missing }; +} + diff --git a/src/cli/subcommand/subcommands.ts b/src/cli/subcommand/subcommands.ts new file mode 100644 index 0000000..c0a8cc7 --- /dev/null +++ b/src/cli/subcommand/subcommands.ts @@ -0,0 +1,336 @@ +/** + * Subcommand router. + * + * Detects `config`, `start`, `daemon` (or `d`) as the first positional + * and routes directly to handler functions. + * + */ +import { cliOptions } from "../options.js"; +import { parseCliArgs } from "../../utils/parseArgs.js"; +import { buildFinalConfig } from "../buildConfig.js"; +import CLIPrinter from "../../utils/printer.js"; +import { configureLogger, logger } from "../../logger.js"; +import { + printConfigList, + printConfigDetail, + findConfig, + saveConfig, + deleteConfig, + validateName, + updateConfigAutoStart, + updateTunnelConfig, + SavedTunnelConfig, + SUBCOMMANDS, + Subcommand, + ConfigVerb, +} from "../configStore.js"; +import { startRemoteManagement, buildRemoteManagementWsUrl } from "../../remote_management/remoteManagement.js"; +import { daemonLostMessage } from "../../utils/daemonLostMessage.js"; +import { handleDaemon } from "./handlers/daemonCommandsHandler.js"; +import { handlePs } from "./handlers/psCommand.js"; +import { handleStop } from "./handlers/stopCommand.js"; +import { handleAttach } from "./handlers/attachCommand.js"; +import { handleLogs } from "./handlers/logsCommand.js"; +import { handleLog } from "./handlers/logCommand.js"; +import { handleRestart } from "./handlers/restartCommand.js"; +import { TunnelClient } from "../../daemon/tunnelClient.js"; +import { + startForegroundViaDaemon, + startMultipleForegroundViaDaemon, + startBackgroundTunnels, + startAutoStartTunnels, +} from "../startCli.js"; +import { printConfigHelp, printStartHelp } from "../../utils/helpMessages.js"; + +const SUBCOMMAND_SET = new Set(SUBCOMMANDS); + +export function isSubcommand(rawArgs: string[]): boolean { + return rawArgs.length > 0 && SUBCOMMAND_SET.has(rawArgs[0]); +} + +/** + * Route and execute a subcommand. + */ +export async function handleSubcommand(rawArgs: string[]): Promise { + const sub = rawArgs[0]; + const rest = rawArgs.slice(1); + + switch (sub as Subcommand) { + case Subcommand.Config: + handleConfig(rest); + return; + case Subcommand.Start: + await handleStart(rest); + return; + case Subcommand.Stop: + await handleStop(rest); + return; + case Subcommand.Ps: + await handlePs(); + return; + case Subcommand.Attach: + await handleAttach(rest); + return; + case Subcommand.Daemon: + case Subcommand.DaemonAlias: + await handleDaemon(rest); + return; + case Subcommand.Logs: { + const follow = rest.includes("-f"); + const nonFlagArgs = rest.filter((a) => a !== "-f"); + await handleLogs(nonFlagArgs, follow); + return; + } + case Subcommand.Log: + await handleLog(rest); + return; + case Subcommand.Restart: + await handleRestart(rest); + return; + } +} + + + function handleConfig(args: string[]): void { + if (args.length === 0) { + printConfigHelp(); + return; + } + + const verb = args[0]; + const rest = args.slice(1); + + switch (verb as ConfigVerb) { + case ConfigVerb.List: + case ConfigVerb.Ls: + printConfigList(); + return; + + case ConfigVerb.Show: { + const names = requireNames(rest, "config show"); + for (const name of names) { + const saved = resolveConfig(name); + if (saved) printConfigDetail(saved); + } + return; + } + + case ConfigVerb.Save: { + const name = requireName(rest, "config save"); + handleConfigSave(name, rest.slice(1)); + return; + } + + case ConfigVerb.Delete: { + const names = requireNames(rest, "config delete"); + for (const name of names) { + const deletedName = deleteConfig(name); + if (deletedName) { + CLIPrinter.success(`Config "${deletedName}" deleted.`); + } else { + CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); + } + } + return; + } + + case ConfigVerb.Update: { + const name = requireName(rest, "config update"); + handleConfigUpdate(name, rest.slice(1)); + return; + } + + case ConfigVerb.Auto: { + const names = requireNames(rest, "config auto"); + for (const name of names) { + const updated = updateConfigAutoStart(name, true); + if (updated) { + CLIPrinter.success(`Config "${updated.name}" auto-start set to on.`); + } else { + CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); + } + } + return; + } + + case ConfigVerb.Noauto: { + const names = requireNames(rest, "config noauto"); + for (const name of names) { + const updated = updateConfigAutoStart(name, false); + if (updated) { + CLIPrinter.success(`Config "${updated.name}" auto-start set to off.`); + } else { + CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); + } + } + return; + } + + default: + // Treat unknown verb as a config name: `pinggy config my-tunnel` + const saved = resolveConfig(verb); + if (saved) printConfigDetail(saved); + return; + } +} + + function handleConfigSave(name: string, remainingArgs: string[]): void { + const nameErr = validateName(name); + if (nameErr) { + CLIPrinter.error(nameErr.message); + process.exit(1); + } + + // Parse remaining args as tunnel flags + const { values, positionals } = parseCliArgs(cliOptions, remainingArgs); + const autoStart = !!values.auto; + + logger.debug("Building config for save", { name, values, positionals }); + const finalConfig = buildFinalConfig(values, positionals); + finalConfig.name = name; + + saveConfig(name, finalConfig.configId!, finalConfig, autoStart); + CLIPrinter.success(`Config "${name}" saved.`); +} + + function handleConfigUpdate(nameOrId: string, remainingArgs: string[]): void { + const saved = resolveConfig(nameOrId); + if (!saved) return; + + // Parse remaining args as tunnel overrides + const { values, positionals } = parseCliArgs(cliOptions, remainingArgs); + + logger.debug("Building updated config", { nameOrId, values, positionals }); + const updatedConfig = buildFinalConfig(values, positionals, saved.tunnelConfig); + updatedConfig.name = saved.name; + const result = updateTunnelConfig(nameOrId, updatedConfig); + if (result) { + CLIPrinter.success(`Config "${result.name}" updated.`); + printConfigDetail(result); + } else { + CLIPrinter.error(`Failed to update config "${nameOrId}".`); + } +} + +async function handleStart(args: string[]): Promise { + // Collect tunnel names (everything before the first flag) + const names: string[] = []; + let i = 0; + while (i < args.length && !args[i].startsWith("-")) { + names.push(args[i]); + i++; + } + const flagArgs = args.slice(i); + + const { values, positionals } = parseCliArgs(cliOptions, flagArgs); + configureLogger(values); + + if (values.all) { + await initRemoteManagementBackground(values); + await startAutoStartTunnels(); + return; + } + + if (names.length === 0) { + printStartHelp(); + return; + } + + // Resolve all configs + const resolved: SavedTunnelConfig[] = []; + for (const name of names) { + const saved = resolveConfig(name); + if (!saved) return; + resolved.push(saved); + } + + // Multiple tunnels + override flags → error + if (resolved.length > 1 && flagArgs.length > 0) { + CLIPrinter.error("Runtime overrides (-l, --type, etc.) can only be used when starting a single tunnel."); + CLIPrinter.print(" Start one tunnel: pinggy start my-tunnel -l 4000"); + CLIPrinter.print(" Or update first: pinggy config update my-tunnel -l 4000"); + return; + } + + await initRemoteManagementBackground(values); + + // Background mode: route through daemon + if (values.b) { + await startBackgroundTunnels(resolved, values, positionals); + return; + } + + if (resolved.length === 1) { + const saved = resolved[0]; + logger.debug("Building config with overrides", { name: saved.name }); + const finalConfig = buildFinalConfig(values, positionals, saved.tunnelConfig); + + await startForegroundViaDaemon(finalConfig); + } else { + await startMultipleForegroundViaDaemon(resolved, values, positionals); + } +} + + +function resolveConfig(nameOrId: string): SavedTunnelConfig | null { + const saved = findConfig(nameOrId); + if (!saved) { + CLIPrinter.error(`No config found matching "${nameOrId}". Use: pinggy config list`); + return null; + } + return saved; +} + +function requireName(args: string[], command: string): string { + if (args.length === 0 || args[0].startsWith("-")) { + CLIPrinter.error(`Tunnel name is required. Usage: pinggy ${command} `); + process.exit(1); + } + return args[0]; +} + +/** + * Collect all non-flag args as names. At least one is required. + */ +function requireNames(args: string[], command: string): string[] { + const names: string[] = []; + for (const arg of args) { + if (arg.startsWith("-")) break; + names.push(arg); + } + if (names.length === 0) { + CLIPrinter.error(`At least one tunnel name is required. Usage: pinggy ${command} [name2 ...]`); + process.exit(1); + } + + return names; +} + +type CliValues = ReturnType>["values"]; + +async function initRemoteManagementBackground(values: CliValues): Promise { + const rmToken = values["remote-management"]; + if (typeof rmToken === "string" && rmToken.trim().length > 0) { + const manageHost = values["manage"]; + try { + // Ensure daemon is running so remote management routes tunnel ops through it + const handler = await TunnelClient.forRemoteManagement(); + + handler.onDaemonLost((reason, detail) => { + CLIPrinter.error(daemonLostMessage(reason, detail)); + setImmediate(() => process.exit(3)); + }); + + await startRemoteManagement({ + apiKey: rmToken, + serverUrl: buildRemoteManagementWsUrl(manageHost), + }, handler); + } catch (e) { + logger.error("Failed to initiate remote management:", e); + CLIPrinter.fatal(e); + } + } +} + + + diff --git a/src/cli/subcommands.ts b/src/cli/subcommands.ts deleted file mode 100644 index 79042cc..0000000 --- a/src/cli/subcommands.ts +++ /dev/null @@ -1,390 +0,0 @@ -/** - * Subcommand router. - * - * Detects `config` and `start` as the first positional and routes - * directly to handler functions. No translation to internal flags. - * - * Rule: if process.argv[2] is `config` or `start`, we're in subcommand - * mode. Otherwise, it's the tunnel-creation flow (token@server, -R, -l). - */ -import { TunnelManager } from "../tunnel_manager/TunnelManager.js"; -import { cliOptions } from "./options.js"; -import { parseCliArgs } from "../utils/parseArgs.js"; -import { buildFinalConfig } from "./buildConfig.js"; -import { startCli } from "./starCli.js"; -import CLIPrinter from "../utils/printer.js"; -import { FinalConfig } from "../types.js"; -import { configureLogger, logger } from "../logger.js"; -import pico from "picocolors"; -import { - printConfigList, - printConfigDetail, - findConfig, - saveConfig, - deleteConfig, - validateName, - updateConfigAutoStart, - updateTunnelConfig, - getAutoStartConfigs, - SavedTunnelConfig, -} from "./configStore.js"; -import { startRemoteManagement, buildRemoteManagementWsUrl } from "../remote_management/remoteManagement.js"; - -const SUBCOMMANDS = new Set(["config", "start"]); - -/** - * Check if the raw args start with a known subcommand. - */ -export function isSubcommand(rawArgs: string[]): boolean { - return rawArgs.length > 0 && SUBCOMMANDS.has(rawArgs[0]); -} - -/** - * Route and execute a subcommand. Call only after isSubcommand() returns true. - */ -export async function handleSubcommand(rawArgs: string[], manager: TunnelManager): Promise { - const sub = rawArgs[0]; - const rest = rawArgs.slice(1); - - switch (sub) { - case "config": - await handleConfig(rest); - return; - case "start": - await handleStart(rest, manager); - return; - } -} - -// ─── config [name] [flags] ─────────────────────────────────────── - -async function handleConfig(args: string[]): Promise { - if (args.length === 0) { - printConfigHelp(); - return; - } - - const verb = args[0]; - const rest = args.slice(1); - - switch (verb) { - case "list": - case "ls": - printConfigList(); - return; - - case "show": { - const names = requireNames(rest, "config show"); - for (const name of names) { - const saved = resolveConfig(name); - if (saved) printConfigDetail(saved); - } - return; - } - - case "save": { - const name = requireName(rest, "config save"); - await handleConfigSave(name, rest.slice(1)); - return; - } - - case "delete": { - const names = requireNames(rest, "config delete"); - for (const name of names) { - const deletedName = deleteConfig(name); - if (deletedName) { - CLIPrinter.success(`Config "${deletedName}" deleted.`); - } else { - CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); - } - } - return; - } - - case "update": { - const name = requireName(rest, "config update"); - await handleConfigUpdate(name, rest.slice(1)); - return; - } - - case "auto": { - const names = requireNames(rest, "config auto"); - for (const name of names) { - const updated = updateConfigAutoStart(name, true); - if (updated) { - CLIPrinter.success(`Config "${updated.name}" auto-start set to on.`); - } else { - CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); - } - } - return; - } - - case "noauto": { - const names = requireNames(rest, "config noauto"); - for (const name of names) { - const updated = updateConfigAutoStart(name, false); - if (updated) { - CLIPrinter.success(`Config "${updated.name}" auto-start set to off.`); - } else { - CLIPrinter.error(`No config found matching "${name}". Use: pinggy config list`); - } - } - return; - } - - default: - // Treat unknown verb as a config name: `pinggy config my-tunnel` - const saved = resolveConfig(verb); - if (saved) printConfigDetail(saved); - return; - } -} - -async function handleConfigSave(name: string, remainingArgs: string[]): Promise { - const nameErr = validateName(name); - if (nameErr) { - CLIPrinter.error(nameErr.message); - process.exit(1); - } - - // Parse remaining args as tunnel flags - const { values, positionals } = parseCliArgs(cliOptions, remainingArgs); - const autoStart = !!values.auto; - - logger.debug("Building config for save", { name, values, positionals }); - const finalConfig = await buildFinalConfig(values, positionals); - - saveConfig(name, finalConfig.configId!, finalConfig, autoStart); - CLIPrinter.success(`Config "${name}" saved.`); -} - -async function handleConfigUpdate(nameOrId: string, remainingArgs: string[]): Promise { - const saved = resolveConfig(nameOrId); - if (!saved) return; - - // Parse remaining args as tunnel overrides - const { values, positionals } = parseCliArgs(cliOptions, remainingArgs); - - logger.debug("Building updated config", { nameOrId, values, positionals }); - const updatedConfig = await buildFinalConfig(values, positionals, saved.tunnelConfig); - - const result = updateTunnelConfig(nameOrId, updatedConfig); - if (result) { - CLIPrinter.success(`Config "${result.name}" updated.`); - printConfigDetail(result); - } else { - CLIPrinter.error(`Failed to update config "${nameOrId}".`); - } -} - -// ─── start [names...] [flags] ─────────────────────────────────────────── - -async function handleStart(args: string[], manager: TunnelManager): Promise { - // Check for --all before collecting names (it's not a cliOptions flag) - const startAll = args.includes("--all"); - const argsWithoutAll = args.filter((a) => a !== "--all"); - - // Collect tunnel names (everything before the first flag) - const names: string[] = []; - let i = 0; - while (i < argsWithoutAll.length && !argsWithoutAll[i].startsWith("-")) { - names.push(argsWithoutAll[i]); - i++; - } - const flagArgs = argsWithoutAll.slice(i); - - // Parse flags early so logging works for all paths - const { values, positionals } = parseCliArgs(cliOptions, flagArgs); - configureLogger(values); - - if (startAll) { - await initRemoteManagementBackground(values); - await startAutoStartTunnels(manager); - return; - } - - if (names.length === 0) { - printStartHelp(); - return; - } - - // Resolve all configs - const resolved: SavedTunnelConfig[] = []; - for (const name of names) { - const saved = resolveConfig(name); - if (!saved) return; - resolved.push(saved); - } - - // Multiple tunnels + override flags → error - if (resolved.length > 1 && flagArgs.length > 0) { - CLIPrinter.error("Runtime overrides (-l, --type, etc.) can only be used when starting a single tunnel."); - CLIPrinter.print(" Start one tunnel: pinggy start my-tunnel -l 4000"); - CLIPrinter.print(" Or update first: pinggy config update my-tunnel -l 4000"); - return; - } - - await initRemoteManagementBackground(values); - - if (resolved.length === 1) { - const saved = resolved[0]; - logger.debug("Building config with overrides", { name: saved.name }); - const finalConfig = await buildFinalConfig(values, positionals, saved.tunnelConfig); - finalConfig.configId = saved.configId; - - await startCli(finalConfig, manager); - } else { - await startNamedTunnels(resolved, manager); - } -} - -// ─── Shared tunnel starters ──────────────────────────────────────────── - -async function startAutoStartTunnels(manager: TunnelManager): Promise { - const configs = getAutoStartConfigs(); - if (configs.length === 0) { - CLIPrinter.warn("No configs marked for auto-start. Use: pinggy config auto "); - return; - } - - CLIPrinter.print(pico.cyanBright(`Starting ${configs.length} auto-start tunnel(s)...`)); - for (const saved of configs) { - await startSavedTunnel(saved, manager); - } - - CLIPrinter.print(pico.gray("\nAll auto-start tunnels launched. Press Ctrl+C to stop.\n")); - await new Promise(() => {}); // Keep process alive -} - -async function startNamedTunnels(configs: SavedTunnelConfig[], manager: TunnelManager): Promise { - CLIPrinter.print(pico.cyanBright(`Starting ${configs.length} tunnel(s)...`)); - for (const saved of configs) { - await startSavedTunnel(saved, manager); - } - - CLIPrinter.print(pico.gray("\nAll tunnels launched. Press Ctrl+C to stop.\n")); - await new Promise(() => {}); // Keep process alive -} - -async function startSavedTunnel(saved: SavedTunnelConfig, manager: TunnelManager): Promise { - const config: FinalConfig = { - ...saved.tunnelConfig, - configId: saved.configId, - name: saved.name, - optional: { - ...saved.tunnelConfig.optional, - noTui: true, - }, - }; - - try { - const tunnel = await manager.createTunnel(config); - await manager.startTunnel(tunnel.tunnelid); - - const urls = await manager.getTunnelUrls(tunnel.tunnelid); - CLIPrinter.success(`"${saved.name}" started`); - (urls ?? []).forEach((url: string) => - CLIPrinter.print(" " + pico.magentaBright(url)) - ); - - manager.registerWorkerErrorListner(tunnel.tunnelid, (_id: string, error: Error) => { - CLIPrinter.error(`[${saved.name}] Fatal: ${error.message}`); - }); - manager.registerDisconnectListener(tunnel.tunnelid, async (_id, error, messages) => { - if (error) CLIPrinter.warn(`[${saved.name}] Disconnected: ${error}`); - messages?.forEach((m) => CLIPrinter.warn(`[${saved.name}] ${m}`)); - }); - manager.registerReconnectingListener(tunnel.tunnelid, (_id, retryCnt) => { - CLIPrinter.print(pico.gray(`[${saved.name}] Reconnecting (attempt #${retryCnt})...`)); - }); - manager.registerReconnectionCompletedListener(tunnel.tunnelid, async (_id, urls) => { - CLIPrinter.success(`[${saved.name}] Reconnected`); - (urls ?? []).forEach((url: string) => - CLIPrinter.print(" " + pico.magentaBright(url)) - ); - }); - manager.registerReconnectionFailedListener(tunnel.tunnelid, (_id, retryCnt) => { - CLIPrinter.error(`[${saved.name}] Reconnection failed after ${retryCnt} attempts`); - }); - } catch (err: any) { - CLIPrinter.error(`[${saved.name}] Failed to start: ${err.message || err}`); - } -} - -// ─── Helpers ──────────────────────────────────────────────────────────── - -function resolveConfig(nameOrId: string): SavedTunnelConfig | null { - const saved = findConfig(nameOrId); - if (!saved) { - CLIPrinter.error(`No config found matching "${nameOrId}". Use: pinggy config list`); - return null; - } - return saved; -} - -function requireName(args: string[], command: string): string { - if (args.length === 0 || args[0].startsWith("-")) { - CLIPrinter.error(`Tunnel name is required. Usage: pinggy ${command} `); - process.exit(1); - } - return args[0]; -} - -/** - * Collect all non-flag args as names. At least one is required. - */ -function requireNames(args: string[], command: string): string[] { - const names: string[] = []; - for (const arg of args) { - if (arg.startsWith("-")) break; - names.push(arg); - } - if (names.length === 0) { - CLIPrinter.error(`At least one tunnel name is required. Usage: pinggy ${command} [name2 ...]`); - process.exit(1); - } - - return names; -} - -type CliValues = ReturnType>["values"]; - -async function initRemoteManagementBackground(values: CliValues): Promise { - const rmToken = values["remote-management"]; - if (typeof rmToken === "string" && rmToken.trim().length > 0) { - const manageHost = values["manage"]; - try { - await startRemoteManagement({ - apiKey: rmToken, - serverUrl: buildRemoteManagementWsUrl(manageHost), - }); - } catch (e) { - logger.error("Failed to initiate remote management:", e); - CLIPrinter.fatal(e); - } - } -} - -// ─── Help messages ────────────────────────────────────────────────────── - -function printConfigHelp(): void { - console.log("\nUsage: pinggy config [name] [options]\n"); - console.log("Commands:"); - console.log(" list List all saved configs"); - console.log(" show Show config details"); - console.log(" save [tunnel flags] Save a tunnel config"); - console.log(" update [tunnel flags] Update a saved config"); - console.log(" delete Delete a saved config"); - console.log(" auto Enable auto-start"); - console.log(" noauto Disable auto-start\n"); -} - -function printStartHelp(): void { - console.log("\nUsage: pinggy start [options]\n"); - console.log("Examples:"); - console.log(" pinggy start my-tunnel Start a saved tunnel"); - console.log(" pinggy start my-tunnel -l 4000 Start with override"); - console.log(" pinggy start tunnela tunnelb Start multiple tunnels"); - console.log(" pinggy start --all Start all auto-start tunnels\n"); -} diff --git a/src/daemon/daemonHealth.ts b/src/daemon/daemonHealth.ts new file mode 100644 index 0000000..948aff0 --- /dev/null +++ b/src/daemon/daemonHealth.ts @@ -0,0 +1,146 @@ +/** + * DaemonHealth: monitors liveness of the daemon process and decides when + * to declare it lost. Two signals drive it: + * + * 1. WS abnormal close → triggers a short reconnect loop (3 × 1s). + * During the loop we verify daemon.json still points to the same PID + * we connected to; a PID change means the daemon was respawned and + * the previous session is unrecoverable. + * 2. Heartbeat ping every 5s → after 2 consecutive failures the daemon + * is treated as hung; no reconnect (it won't help). + * + * Subscriptions are owned by WsStream. DaemonHealth asks it to snapshot + * and restore them across a successful reconnect. + */ +import { IPCClient } from "./ipc/ipcClient.js"; +import { getDaemonInfo } from "./lifecycle/daemonManager.js"; +import { logger } from "../logger.js"; +import { errorMessage } from "../utils/util.js"; +import { WsStream, WS_NORMAL_CLOSE } from "./ws/wsStream.js"; + +const RECONNECT_ATTEMPTS = 3; +const RECONNECT_INTERVAL_MS = 1000; +const HEARTBEAT_INTERVAL_MS = 5000; +const HEARTBEAT_TIMEOUT_MS = 2000; +const HEARTBEAT_FAILURE_THRESHOLD = 2; + +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +export type DaemonLostReason = "dead" | "respawned" | "hung" | "heartbeat"; +export type DaemonLostCallback = (reason: DaemonLostReason, detail?: string) => void; +export type DaemonReconnectingCallback = (attempt: number, max: number) => void; +export type DaemonReconnectedCallback = () => void; + +export class DaemonHealth { + private originalPid: number | null = null; + private lost = false; + private reconnecting = false; + private heartbeatTimer: NodeJS.Timeout | null = null; + private lostCallbacks: DaemonLostCallback[] = []; + private reconnectingCallbacks: DaemonReconnectingCallback[] = []; + private reconnectedCallbacks: DaemonReconnectedCallback[] = []; + + constructor( + private getIpc: () => IPCClient | null, + private stream: WsStream, + ) { + // React to WS lifecycle: heartbeat tracks the open socket; an abnormal + // close kicks off the reconnect loop (if there's anything to recover). + this.stream.onOpen(() => this.startHeartbeat()); + this.stream.onClose((code) => { + this.stopHeartbeat(); + if (code === WS_NORMAL_CLOSE) return; + if (this.lost || this.reconnecting) return; + if (!this.stream.hasSubscriptions()) return; + this.reconnecting = true; + this.attemptReconnect() + .catch((err) => logger.debug("Reconnect loop threw", { error: err?.message })) + .finally(() => { this.reconnecting = false; }); + }); + } + + bindPid(pid: number): void { + this.originalPid = pid; + } + + isLost(): boolean { + return this.lost; + } + + onLost(cb: DaemonLostCallback): void { this.lostCallbacks.push(cb); } + onReconnecting(cb: DaemonReconnectingCallback): void { this.reconnectingCallbacks.push(cb); } + onReconnected(cb: DaemonReconnectedCallback): void { this.reconnectedCallbacks.push(cb); } + + startHeartbeat(): void { + this.stopHeartbeat(); + let consecutiveFailures = 0; + this.heartbeatTimer = setInterval(async () => { + if (this.lost || this.reconnecting) return; + const ipc = this.getIpc(); + if (!ipc) return; + try { + await ipc.ping(HEARTBEAT_TIMEOUT_MS); + consecutiveFailures = 0; + } catch (err) { + consecutiveFailures += 1; + if (consecutiveFailures >= HEARTBEAT_FAILURE_THRESHOLD) { + this.triggerLost("heartbeat", errorMessage(err)); + } + } + }, HEARTBEAT_INTERVAL_MS); + } + + stopHeartbeat(): void { + if (this.heartbeatTimer) { + clearInterval(this.heartbeatTimer); + this.heartbeatTimer = null; + } + } + + private async attemptReconnect(): Promise { + const snapshot = this.stream.snapshotSubscriptions(); + + for (let attempt = 1; attempt <= RECONNECT_ATTEMPTS; attempt++) { + for (const cb of this.reconnectingCallbacks) { + try { cb(attempt, RECONNECT_ATTEMPTS); } catch { /* consumer errors must not break loop */ } + } + await sleep(RECONNECT_INTERVAL_MS); + if (this.lost) return; + + const info = getDaemonInfo(); + if (!info) { + this.triggerLost("dead"); + return; + } + if (info.pid !== this.originalPid) { + this.triggerLost("respawned", `was pid ${this.originalPid}, now pid ${info.pid}`); + return; + } + try { + const ipc = this.getIpc(); + if (!ipc) throw new Error("IPC client missing"); + await ipc.ping(HEARTBEAT_TIMEOUT_MS); + await this.stream.restoreSubscriptions(snapshot); + for (const cb of this.reconnectedCallbacks) { + try { cb(); } catch { /* ignore */ } + } + return; + } catch (err) { + logger.debug("Reconnect attempt failed", { attempt, error: errorMessage(err) }); + } + } + this.triggerLost("hung"); + } + + private triggerLost(reason: DaemonLostReason, detail?: string): void { + if (this.lost) return; + this.lost = true; + this.stopHeartbeat(); + this.stream.terminate(); + for (const cb of this.lostCallbacks) { + try { cb(reason, detail); } catch (err) { + logger.debug("daemon-lost callback threw", { error: errorMessage(err) }); + } + } + } +} diff --git a/src/daemon/daemonTunnelHandler.ts b/src/daemon/daemonTunnelHandler.ts new file mode 100644 index 0000000..74a1108 --- /dev/null +++ b/src/daemon/daemonTunnelHandler.ts @@ -0,0 +1,83 @@ +/** + * DaemonTunnelHandler: implements TunnelHandler by routing every operation + * through the daemon's IPC server. Used by remote management when it runs + * inside the CLI process and needs a TunnelHandler-shaped object. + */ +import { TunnelUsageType } from "@pinggy/pinggy"; +import { IPCClient } from "./ipc/ipcClient.js"; +import { SessionMode } from "./ipc/ipcRoutes.js"; +import { TunnelHandler, TunnelResponse, TunnelResponseV2 } from "../remote_management/handler.js"; +import { TunnelConfig, TunnelConfigV1 } from "../remote_management/remote_schema.js"; +import { DisconnectListener } from "../tunnel_manager/TunnelManager.js"; +import { ErrorResponse } from "../types.js"; + +export class DaemonTunnelHandler implements TunnelHandler { + private client: IPCClient; + + constructor(client: IPCClient) { + this.client = client; + } + + // methods for v1 + async handleStart(config: TunnelConfig, noWait?: boolean): Promise { + return this.client.startTunnelV1(config, SessionMode.Detached, noWait); + } + async handleUpdateConfig(config: TunnelConfig, noWait?: boolean): Promise { + return this.client.updateConfig(config, noWait); + } + + async handleUpdateConfigV2(config: TunnelConfigV1, noWait?: boolean): Promise { + return this.client.updateConfigV2(config, noWait); + } + + async handleList(): Promise { + return this.client.listTunnelsV1(); + } + + async handleStartV2(config: TunnelConfigV1, noWait?: boolean): Promise { + return this.client.startTunnelWithConfig(config, SessionMode.Detached, noWait); + } + + async handleListV2(): Promise { + return this.client.listTunnels(); + } + + async handleStop(tunnelid: string): Promise { + return this.client.stopTunnel(tunnelid); + } + + async handleGet(tunnelid: string): Promise { + return this.client.getTunnel(tunnelid); + } + + async handleRestart(tunnelid: string, noWait?: boolean): Promise { + return this.client.restartTunnel(tunnelid); + } + + handleRegisterStatsListener(tunnelid: string, listener: (tunnelId: string, stats: TunnelUsageType) => void): void { + // Stats listeners are handled via WebSocket in TunnelClient, not through this handler. + } + + handleUnregisterStatsListener(tunnelid: string, listnerId: string): void { + // No-op in daemon mode + } + + handleGetTunnelStats(tunnelid: string): Promise { + return Promise.resolve([{ numLiveConnections: 0, numTotalConnections: 0, numTotalReqBytes: 0, numTotalResBytes: 0, numTotalTxBytes: 0, elapsedTime: 0 }]); + } + + handleRegisterDisconnectListener(tunnelid: string, listener: DisconnectListener): void { + // Disconnect listeners are handled via WebSocket in TunnelClient + } + + handleRemoveStoppedTunnelByTunnelId(tunnelId: string): boolean | ErrorResponse { + // Fire and forget — returns a promise but interface expects sync + void this.client.removeStoppedTunnel({ tunnelid: tunnelId }); + return true; + } + + handleRemoveStoppedTunnelByConfigId(configId: string): boolean | ErrorResponse { + void this.client.removeStoppedTunnel({ configId }); + return true; + } +} diff --git a/src/daemon/ipc/ipcClient.ts b/src/daemon/ipc/ipcClient.ts new file mode 100644 index 0000000..7c76df1 --- /dev/null +++ b/src/daemon/ipc/ipcClient.ts @@ -0,0 +1,187 @@ +/** + * IPC HTTP client for communicating with the Pinggy daemon from the foreground CLI. + * Simple, no external dependencies - uses Node's built-in http module. + * + * Route shapes live in ipcRoutes.ts; this file is just transport plus typed wrappers. + */ +import http from "node:http"; +import { + LogLevel, + LogPathsResponse, + ParameterizedRoutes, + ParamRoute, + PingResponse, + ResolveLogPathResponse, + Route, + RouteKey, + RouteReq, + RouteRes, + SessionMode, + ShutdownResponse, +} from "./ipcRoutes.js"; +import { TunnelConfig, TunnelConfigV1 } from "../../remote_management/remote_schema.js"; +import { TunnelResponse, TunnelResponseV2 } from "../../remote_management/handler.js"; +import { ErrorResponse } from "../../types.js"; +import type { TunnelUsageType } from "@pinggy/pinggy"; + +const REQUEST_TIMEOUT_MS = 10000; + +export type ClientOrigin = "app" | "cli" | "remote"; + +export class IPCClient { + private port: number; + private origin: ClientOrigin; + + constructor(port: number, origin: ClientOrigin = "cli") { + this.port = port; + this.origin = origin; + } + + async ping(timeoutMs?: number): Promise { + return this.call(Route.Ping, undefined, timeoutMs); + } + + async listTunnels(): Promise> { + return this.call(Route.ListTunnels, undefined); + } + + async getTunnel(tunnelId: string): Promise { + return this.request("GET", `/tunnels/${tunnelId}`); + } + + async getTunnelStats(tunnelId: string): Promise { + return this.request("GET", `/tunnels/${encodeURIComponent(tunnelId)}/stats`); + } + + async startTunnel(name: string, mode: SessionMode): Promise { + return this.call(Route.StartTunnel, { name, mode }); + } + + async startTunnelWithConfig(config: TunnelConfigV1, mode: SessionMode, noWait?: boolean): Promise { + return this.call(Route.StartTunnelConfig, { config, mode, noWait }); + } + + async stopTunnel(tunnelid: string): Promise { + return this.call(Route.StopTunnel, { tunnelid }); + } + + async restartTunnel(tunnelid: string): Promise { + return this.call(Route.RestartTunnel, { tunnelid }); + } + + // v1 operations (used by remote management via daemon) + async startTunnelV1(config: TunnelConfig, mode: SessionMode, noWait?: boolean): Promise { + return this.call(Route.StartTunnelV1, { config, mode, noWait }); + } + + async listTunnelsV1(): Promise { + return this.call(Route.ListTunnelsV1, undefined); + } + + async updateConfig(config: TunnelConfig, noWait?: boolean): Promise { + return this.call(Route.UpdateConfig, { config, noWait }); + } + + async updateConfigV2(config: TunnelConfigV1, noWait?: boolean): Promise { + return this.call(Route.UpdateConfigV2, { config, noWait }); + } + + async removeStoppedTunnel(opts: { tunnelid?: string; configId?: string }): Promise<{ result: boolean | ErrorResponse }> { + return this.call(Route.RemoveStopped, opts); + } + + async shutdown(): Promise { + return this.call(Route.Shutdown, {}); + } + + async getLogLevel(): Promise<{ level: LogLevel }> { + return this.call(Route.GetLogLevel, undefined); + } + + async setLogLevel(level: LogLevel): Promise<{ level: LogLevel; appliedTo: string }> { + return this.call(Route.SetLogLevel, { level }); + } + + async getTunnelLogging(): Promise<{ enabled: boolean }> { + return this.call(Route.GetTunnelLogging, undefined); + } + + async setTunnelLogging(enabled: boolean): Promise<{ enabled: boolean }> { + return this.call(Route.SetTunnelLogging, { enabled }); + } + + async getLogPaths(): Promise { + return this.call(Route.GetLogPaths, undefined); + } + + async resolveLogPath(q: string): Promise { + return this.request("GET", `/logs/resolve?q=${encodeURIComponent(q)}`); + } + + /** + * Get the WebSocket URL for event streaming. + */ + getWsUrl(): string { + return `ws://127.0.0.1:${this.port}/ws`; + } + + getPort(): number { + return this.port; + } + + private call(key: K, body: RouteReq, timeoutMs?: number): Promise> { + const spaceIdx = key.indexOf(" "); + const method = key.slice(0, spaceIdx); + const path = key.slice(spaceIdx + 1); + const payload = method === "GET" ? undefined : JSON.stringify(body ?? {}); + return this.request>(method, path, payload, timeoutMs); + } + + private request(method: string, path: string, body?: string, timeoutMs?: number): Promise { + return new Promise((resolve, reject) => { + const headers: Record = { + "X-Pinggy-Origin": this.origin, + }; + if (body) { + headers["Content-Type"] = "application/json"; + headers["Content-Length"] = Buffer.byteLength(body); + } + const req = http.request( + { + hostname: "127.0.0.1", + port: this.port, + path, + method, + headers, + timeout: timeoutMs ?? REQUEST_TIMEOUT_MS, + }, + (res) => { + const chunks: Buffer[] = []; + res.on("data", (chunk: Buffer) => chunks.push(chunk)); + res.on("end", () => { + const text = Buffer.concat(chunks).toString("utf-8"); + const statusCode = res.statusCode ?? 0; + if (statusCode < 200 || statusCode >= 300) { + reject(new Error(`Daemon returned HTTP ${statusCode}: ${text.slice(0, 200)}`)); + return; + } + try { + resolve(JSON.parse(text) as T); + } catch { + reject(new Error(`Invalid JSON from daemon: ${text.slice(0, 200)}`)); + } + }); + } + ); + + req.on("error", (err) => reject(new Error(`Cannot connect to daemon: ${err.message}`))); + req.on("timeout", () => { + req.destroy(); + reject(new Error("Daemon request timed out")); + }); + + if (body) req.write(body); + req.end(); + }); + } +} diff --git a/src/daemon/ipc/ipcRoutes.ts b/src/daemon/ipc/ipcRoutes.ts new file mode 100644 index 0000000..7db8d15 --- /dev/null +++ b/src/daemon/ipc/ipcRoutes.ts @@ -0,0 +1,119 @@ +/** + * Typed IPC route contract shared by IPCClient (caller) and IPCServer (handler). + * Each `Route` constant binds a `METHOD PATH` string used over the wire; the + * `IPCRoutes` map binds that same key to its request and response shapes. + * + * Adding a route: add an entry to `Route`, then a matching key in `IPCRoutes`. + * Both sides will fail to compile until the new handler is wired up. + */ +import { TunnelConfig, TunnelConfigV1 } from "../../remote_management/remote_schema.js"; +import { TunnelResponse, TunnelResponseV2 } from "../../remote_management/handler.js"; +import { ErrorResponse } from "../../types.js"; +import { TunnelOrigin } from "../../tunnel_manager/TunnelManager.js"; +import type { TunnelUsageType } from "@pinggy/pinggy"; + +export const Route = { + Ping: "GET /ping", + ListTunnels: "GET /tunnels", + ListTunnelsV1: "GET /tunnels-v1", + StartTunnel: "POST /tunnels/start", + StartTunnelConfig: "POST /tunnels/start-config", + StartTunnelV1: "POST /tunnels/start-v1", + StopTunnel: "POST /tunnels/stop", + RestartTunnel: "POST /tunnels/restart", + UpdateConfig: "POST /tunnels/update-config", + UpdateConfigV2: "POST /tunnels/update-config-v2", + RemoveStopped: "POST /tunnels/remove-stopped", + Shutdown: "POST /shutdown", + GetLogLevel: "GET /loglevel", + SetLogLevel: "POST /loglevel", + GetTunnelLogging: "GET /config/tunnel-logging", + SetTunnelLogging: "POST /config/tunnel-logging", + GetLogPaths: "GET /logs/paths", +} as const; + +export const ParamRoute = { + GetTunnel: "GET /tunnels/:id", + GetTunnelStats: "GET /tunnels/:id/stats", + ResolveLogPath: "GET /logs/resolve", +} as const; + +export interface PingResponse { + status: string; + pid: number; + uptime: number; +} + +export type ListTunnelsResponse = (TunnelResponseV2 & { mode?: SessionMode })[] | ErrorResponse; + +export interface LogPathEntry { + tunnelId?: string; + name?: string; + origin: TunnelOrigin; + path: string; + mtime: number; + running: boolean; +} + +export interface LogPathsResponse { + daemon: string; + tunnels: LogPathEntry[]; +} + +export type ResolveLogPathResponse = + | { status: "running" | "historical"; path: string; tunnelId?: string; name?: string; origin: TunnelOrigin; running: boolean } + | { status: "config-only"; name: string; configId: string } + | { status: "not-found" }; + +export interface ShutdownResponse { + status: string; + errors: string[]; +} + +export type LogLevel = "debug" | "info" | "error"; + +export const SessionMode = { + Foreground: "foreground", + Detached: "detached", +} as const; +export type SessionMode = typeof SessionMode[keyof typeof SessionMode]; + +export type IPCRoutes = { + [Route.Ping]: { req: void; res: PingResponse }; + + [Route.ListTunnels]: { req: void; res: ListTunnelsResponse }; + [Route.ListTunnelsV1]: { req: void; res: TunnelResponse[] | ErrorResponse }; + + [Route.StartTunnel]: { req: { name: string; mode: SessionMode }; res: TunnelResponseV2 | ErrorResponse }; + [Route.StartTunnelConfig]: { req: { config: TunnelConfigV1; mode: SessionMode; noWait?: boolean }; res: TunnelResponseV2 | ErrorResponse }; + [Route.StartTunnelV1]: { req: { config: TunnelConfig; mode: SessionMode; noWait?: boolean }; res: TunnelResponse | ErrorResponse }; + [Route.StopTunnel]: { req: { tunnelid: string }; res: TunnelResponse | ErrorResponse }; + [Route.RestartTunnel]: { req: { tunnelid: string }; res: TunnelResponse | ErrorResponse }; + [Route.UpdateConfig]: { req: { config: TunnelConfig; noWait?: boolean }; res: TunnelResponse | ErrorResponse }; + [Route.UpdateConfigV2]: { req: { config: TunnelConfigV1; noWait?: boolean }; res: TunnelResponseV2 | ErrorResponse }; + [Route.RemoveStopped]: { req: { tunnelid?: string; configId?: string }; res: { result: boolean | ErrorResponse } }; + + [Route.Shutdown]: { req: Record; res: ShutdownResponse }; + + [Route.GetLogLevel]: { req: void; res: { level: LogLevel } }; + [Route.SetLogLevel]: { req: { level: LogLevel }; res: { level: LogLevel; appliedTo: string } }; + + [Route.GetTunnelLogging]: { req: void; res: { enabled: boolean } }; + [Route.SetTunnelLogging]: { req: { enabled: boolean }; res: { enabled: boolean } }; + + [Route.GetLogPaths]: { req: void; res: LogPathsResponse }; +}; + +export type RouteKey = keyof IPCRoutes; +export type RouteReq = IPCRoutes[K]["req"]; +export type RouteRes = IPCRoutes[K]["res"]; + +/** + * Parameterized routes don't fit the static `METHOD PATH` key shape. + * Kept as a small parallel registry. + */ +export type ParameterizedRoutes = { + [ParamRoute.GetTunnel]: { params: { tunnelId: string }; res: TunnelResponse | ErrorResponse }; + [ParamRoute.GetTunnelStats]: { params: { tunnelId: string }; res: TunnelUsageType[] | ErrorResponse }; + [ParamRoute.ResolveLogPath]: { params: { q: string }; res: ResolveLogPathResponse }; +}; diff --git a/src/daemon/ipc/ipcServer.ts b/src/daemon/ipc/ipcServer.ts new file mode 100644 index 0000000..4fd3aae --- /dev/null +++ b/src/daemon/ipc/ipcServer.ts @@ -0,0 +1,708 @@ +/** + * IPC HTTP + WebSocket Server for the Pinggy daemon. + * Listens on 127.0.0.1 with an OS-assigned port. + * HTTP routes delegate to TunnelOperations. + * WebSocket provides real-time event streaming to CLI/App clients. + */ +import http from "node:http"; +import fs from "node:fs"; +import path from "node:path"; +import { WebSocketServer, WebSocket } from "ws"; +import { TunnelOperations, TunnelManager } from "../../main.js"; +import { TunnelConfigV1 } from "../../remote_management/remote_schema.js"; +import { logger, getLogLevel, setLogLevel } from "../../logger.js"; +import { isTunnelLoggingEnabled, setTunnelLoggingEnabled } from "../../logger/tunnelLogger.js"; +import { findConfig, listSavedConfigs } from "../../cli/configStore.js"; +import { getTunnelLogDir, getDaemonLogPath, getTunnelLogPath } from "../../utils/configDir.js"; +import { errorMessage } from "../../utils/util.js"; +import { + IPCRoutes, + ParameterizedRoutes, + ParamRoute, + ResolveLogPathResponse, + Route, + RouteKey, + RouteReq, + RouteRes, + SessionMode, +} from "./ipcRoutes.js"; +import { + ClientMessage, + createTunnelEvent, + DaemonEventType, + parseClientMessage, + TunnelEventPayloadMap, +} from "../ws/wsProtocol.js"; +import { TunnelOrigin } from "../../tunnel_manager/TunnelManager.js"; +import { SessionTracker } from "../lifecycle/sessionTracker.js"; +import { isErrorResponse } from "../../types.js"; +import { removeDaemonInfo, trackIPCTunnelStart, trackTunnelStop } from "../lifecycle/daemonChild.js"; +import { clearDaemonState } from "../lifecycle/stateStore.js"; + +const VALID_ORIGINS: TunnelOrigin[] = ["app", "cli", "remote"]; + +function parseOrigin(req: http.IncomingMessage): TunnelOrigin { + const raw = req.headers["x-pinggy-origin"]; + const v = Array.isArray(raw) ? raw[0] : raw; + return v && (VALID_ORIGINS as string[]).includes(v) ? (v as TunnelOrigin) : "cli"; +} + +function parseBody(method: string, body: string): unknown { + if (method === "GET") return undefined; + if (!body) return {}; + return JSON.parse(body); +} + +const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * Parse a tunnel log filename. Expected formats: + * __.log (saved/named tunnel) + * __.log (ad-hoc tunnel) + * ____.log (legacy; pre single-file-per-name change) + * Returns null if the filename does not start with a recognized origin. + */ +function parseTunnelLogFilename(filename: string): { origin: TunnelOrigin; name?: string; tunnelId?: string } | null { + const base = filename.replace(/\.log$/, ""); + const parts = base.split("__"); + if (parts.length < 2) return null; + const origin = parts[0] as TunnelOrigin; + if (!(VALID_ORIGINS as string[]).includes(origin)) return null; + if (parts.length === 2) { + const second = parts[1]; + if (UUID_RE.test(second)) return { origin, tunnelId: second }; + return { origin, name: second }; + } + // 3+ parts: legacy ____.log + const tunnelId = parts[parts.length - 1]; + const name = parts.slice(1, -1).join("__"); + return { origin, name, tunnelId }; +} + +interface RouteContext { + origin: TunnelOrigin; +} + +type RouteHandler = (req: RouteReq, ctx: RouteContext) => RouteRes | Promise>; + +type RouteHandlers = { [K in RouteKey]: RouteHandler }; + +type ParamHandler = (params: ParameterizedRoutes[K]["params"]) => Promise; + +export interface WsSubscription { + tunnelId: string; + mode: SessionMode; +} + +export interface WsSession { + id: string; + ws: WebSocket; + subscriptions: Map; // tunnelId → subscription + listenerIds: Map; // tunnelId → array of listener IDs to deregister +} + +export type OnSessionDisconnect = (session: WsSession) => void; + +export class IPCServer { + private server: http.Server; + private wss: WebSocketServer; + private ops: TunnelOperations; + private startedAt: number; + private routes: RouteHandlers; + private sessions: Map = new Map(); + private sessionCounter = 0; + private onSessionDisconnect: OnSessionDisconnect | null = null; + private sessionTracker: SessionTracker | null = null; + + constructor() { + this.ops = new TunnelOperations(); + this.startedAt = Date.now(); + this.server = http.createServer(this.handleRequest.bind(this)); + this.wss = new WebSocketServer({ noServer: true }); + this.routes = this.buildRoutes(); + this.setupWebSocket(); + } + + /** + * Set callback for when a WS session disconnects. + * Used by SessionTracker for orphan cleanup. + */ + setOnSessionDisconnect(cb: OnSessionDisconnect): void { + this.onSessionDisconnect = cb; + } + + setSessionTracker(st: SessionTracker): void { + this.sessionTracker = st; + } + + /** + * Get all active sessions (for SessionTracker inspection). + */ + getSessions(): Map { + return this.sessions; + } + + private buildRoutes(): RouteHandlers { + return { + [Route.Ping]: () => ({ + status: "ok", + pid: process.pid, + uptime: Math.floor((Date.now() - this.startedAt) / 1000), + }), + + [Route.ListTunnels]: async () => { + const res = await this.ops.handleListV2(); + if (isErrorResponse(res)) return res; + return res.map((t) => ({ + ...t, + mode: this.sessionTracker?.getOwnership(t.tunnelid)?.mode, + })); + }, + + [Route.ListTunnelsV1]: async () => { + return await this.ops.handleList(); + }, + + [Route.StartTunnel]: async (req, ctx) => { + if (!req.name) throw new Error("Missing 'name' field"); + const saved = findConfig(req.name); + if (!saved) throw new Error(`No config found matching "${req.name}"`); + + const config = { + ...saved.tunnelConfig, + configId: saved.configId, + name: saved.name, + } as TunnelConfigV1; + const result = await this.ops.handleStartV2(config, false, ctx.origin); + if (!isErrorResponse(result)) { + trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode); + } + return result; + }, + + [Route.StartTunnelConfig]: async (req, ctx) => { + if (!req?.config) throw new Error("Missing 'config' field"); + const result = await this.ops.handleStartV2(req.config, req.noWait, ctx.origin); + if (!isErrorResponse(result)) { + trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode); + } + return result; + }, + + [Route.StartTunnelV1]: async (req, ctx) => { + if (!req.config) throw new Error("Missing 'config' field"); + const result = await this.ops.handleStart(req.config, req.noWait, ctx.origin); + if (!isErrorResponse(result)) { + trackIPCTunnelStart(result.tunnelid, ctx.origin, req.mode); + } + return result; + }, + + [Route.StopTunnel]: async (req) => { + if (!req.tunnelid) throw new Error("Missing 'tunnelid' field"); + const result = await this.ops.handleStop(req.tunnelid); + this.sessionTracker?.removeTunnel(req.tunnelid); + if (!isErrorResponse(result)) { + trackTunnelStop(req.tunnelid); + } + return result; + }, + + [Route.RestartTunnel]: async (req) => { + if (!req.tunnelid) throw new Error("Missing 'tunnelid' field"); + return await this.ops.handleRestart(req.tunnelid); + }, + + [Route.UpdateConfig]: async (req) => { + if (!req.config) throw new Error("Missing 'config' field"); + return await this.ops.handleUpdateConfig(req.config, req.noWait); + }, + + [Route.UpdateConfigV2]: async (req) => { + if (!req.config) throw new Error("Missing 'config' field"); + return await this.ops.handleUpdateConfigV2(req.config, req.noWait); + }, + + [Route.RemoveStopped]: (req) => { + if (req.tunnelid) return { result: this.ops.handleRemoveStoppedTunnelByTunnelId(req.tunnelid) }; + if (req.configId) return { result: this.ops.handleRemoveStoppedTunnelByConfigId(req.configId) }; + throw new Error("Missing 'tunnelid' or 'configId' field"); + }, + + [Route.GetLogLevel]: () => { + return { level: getLogLevel() as "debug" | "info" | "error" }; + }, + + [Route.SetLogLevel]: (req) => { + if (!["debug", "info", "error"].includes(req.level)) { + throw new Error(`Invalid log level: ${req.level}. Must be debug, info, or error`); + } + setLogLevel(req.level); + return { level: req.level, appliedTo: "daemon-js+active-workers-js+future-workers" }; + }, + + [Route.GetTunnelLogging]: () => { + return { enabled: isTunnelLoggingEnabled() }; + }, + + [Route.SetTunnelLogging]: (req) => { + if (typeof req.enabled !== "boolean") throw new Error("Missing 'enabled' boolean"); + setTunnelLoggingEnabled(req.enabled); + return { enabled: isTunnelLoggingEnabled() }; + }, + + [Route.GetLogPaths]: async () => { + const logDir = getTunnelLogDir(); + const daemonPath = getDaemonLogPath(); + const tunnels: IPCRoutes[typeof Route.GetLogPaths]["res"]["tunnels"] = []; + + if (fs.existsSync(logDir)) { + const files = fs.readdirSync(logDir).filter((f: string) => f.endsWith(".log") && !/\.log\.\d+$/.test(f)); + const manager = TunnelManager.getInstance(); + const activeIds = manager.getActiveTunnelIds(); + const activeNames = new Set(); + for (const t of await manager.getAllTunnels()) { + if (!activeIds.has(t.tunnelid)) continue; + const n = t.tunnelName ?? t.tunnelConfig?.name; + if (n) activeNames.add(n); + } + + for (const file of files) { + const filePath = path.join(logDir, file); + const stat = fs.statSync(filePath); + const parsed = parseTunnelLogFilename(file); + if (!parsed) continue; + + const running = parsed.tunnelId + ? activeIds.has(parsed.tunnelId) + : parsed.name ? activeNames.has(parsed.name) : false; + + tunnels.push({ + tunnelId: parsed.tunnelId, + name: parsed.name, + origin: parsed.origin, + path: filePath, + mtime: stat.mtimeMs, + running, + }); + } + } + + return { daemon: daemonPath, tunnels }; + }, + + [Route.Shutdown]: () => { + logger.info("Daemon shutdown requested via IPC"); + const errors: string[] = []; + const step = (label: string, fn: () => void) => { + try { fn(); } catch (e) { + const msg = errorMessage(e); + errors.push(`${label}: ${msg}`); + logger.error(`Shutdown step "${label}" failed`, { error: msg }); + } + }; + + // Remove pid/state files first so the next CLI run isn't blocked + // even if a later step throws. + step("removeDaemonInfo", removeDaemonInfo); + step("clearDaemonState", clearDaemonState); + step("stopAllTunnels", () => TunnelManager.getInstance().stopAllTunnels()); + + setTimeout(() => process.exit(0), 200); + return { status: "shutting_down", errors }; + }, + }; + } + + private async handleRequest(req: http.IncomingMessage, res: http.ServerResponse): Promise { + const method = req.method ?? "GET"; + const url = req.url ?? "/"; + const ctx: RouteContext = { origin: parseOrigin(req) }; + + // Strip query string for static lookup; parameterized routes parse the URL themselves. + const pathOnly = url.split("?")[0]; + const routeKey = `${method} ${pathOnly}` as RouteKey; + // The handler lookup loses the per-key correlation; cast to a generic + // shape on dispatch. Per-handler bodies remain strongly typed at definition. + type AnyHandler = (req: unknown, ctx: RouteContext) => Promise; + const handler = (this.routes as Record)[routeKey]; + + if (handler) { + try { + const body = await this.readBody(req); + const parsed = parseBody(method, body); + const result = await handler(parsed, ctx); + this.sendJson(res, 200, result as object); + } catch (err) { + const msg = errorMessage(err); + logger.error("IPC handler error", { routeKey, error: msg }); + this.sendJson(res, 500, { error: msg }); + } + return; + } + + const paramMatch = this.matchParameterizedRoute(method, url); + if (paramMatch) { + try { + await this.readBody(req); + const response = await paramMatch.invoke(); + this.sendJson(res, 200, response as object); + } catch (err) { + const msg = errorMessage(err); + logger.error("IPC handler error", { route: `${method} ${url}`, error: msg }); + this.sendJson(res, 500, { error: msg }); + } + return; + } + + this.sendJson(res, 404, { error: "Not found", path: url }); + } + + private matchParameterizedRoute(method: string, url: string): { invoke: () => Promise } | null { + if (method === "GET") { + const statsMatch = url.match(/^\/tunnels\/([^/?]+)\/stats(?:\?.*)?$/); + if (statsMatch) { + const tunnelId = statsMatch[1]; + const handler: ParamHandler = async ({ tunnelId: id }) => this.ops.handleGetTunnelStats(id); + return { invoke: () => handler({ tunnelId }) }; + } + + const match = url.match(/^\/tunnels\/([^/?]+)(?:\?.*)?$/); + if (match) { + const tunnelId = match[1]; + const handler: ParamHandler = async ({ tunnelId: id }) => this.ops.handleGet(id); + return { invoke: () => handler({ tunnelId }) }; + } + } + + if (method === "GET" && url.startsWith("/logs/resolve")) { + const urlObj = new URL(url, "http://localhost"); + const q = urlObj.searchParams.get("q") || ""; + const handler: ParamHandler = async ({ q: query }) => this.resolveLogPath(query); + return { invoke: () => handler({ q }) }; + } + + return null; + } + + private async resolveLogPath(q: string): Promise { + if (!q) return { status: "not-found" }; + + const logDir = getTunnelLogDir(); + + // 1. Active tunnel match. Stopped records fall through to the + // filesystem scan so the newest file by mtime wins. + const manager = TunnelManager.getInstance(); + const activeIds = manager.getActiveTunnelIds(); + + for (const t of await manager.getAllTunnels()) { + if (!activeIds.has(t.tunnelid)) continue; + const name = t.tunnelName ?? t.tunnelConfig?.name; + if (name === q || t.tunnelid === q || t.tunnelid.startsWith(q)) { + const origin: TunnelOrigin = "cli"; + const logPath = getTunnelLogPath(t.tunnelid, origin, name); + return { status: "running", path: logPath, tunnelId: t.tunnelid, name, origin, running: true }; + } + } + + // 2. Filesystem scan for historical files. Returns the newest match by mtime. + if (fs.existsSync(logDir)) { + const files = fs.readdirSync(logDir).filter((f: string) => f.endsWith(".log") && !/\.log\.\d+$/.test(f)); + const matches: Array<{ path: string; mtime: number; tunnelId?: string; name?: string; origin: TunnelOrigin }> = []; + + for (const file of files) { + const parsed = parseTunnelLogFilename(file); + if (!parsed) continue; + + const nameMatch = parsed.name !== undefined && parsed.name === q; + const idMatch = parsed.tunnelId !== undefined && (parsed.tunnelId === q || parsed.tunnelId.startsWith(q)); + + if (nameMatch || idMatch) { + const filePath = path.join(logDir, file); + const stat = fs.statSync(filePath); + matches.push({ path: filePath, mtime: stat.mtimeMs, tunnelId: parsed.tunnelId, name: parsed.name, origin: parsed.origin }); + } + } + + if (matches.length > 0) { + matches.sort((a, b) => b.mtime - a.mtime); + const best = matches[0]; + return { status: "historical", path: best.path, tunnelId: best.tunnelId, name: best.name, origin: best.origin, running: false }; + } + } + + // 3. Check saved configs (config-only case) + const saved = listSavedConfigs(); + const matchedConfig = saved.find(c => c.name === q || c.configId === q || c.configId.startsWith(q)); + if (matchedConfig) { + return { status: "config-only", name: matchedConfig.name, configId: matchedConfig.configId }; + } + + return { status: "not-found" }; + } + + private setupWebSocket(): void { + this.server.on("upgrade", (req, socket, head) => { + if (req.url === "/ws") { + this.wss.handleUpgrade(req, socket, head, (ws) => { + this.wss.emit("connection", ws, req); + }); + } else { + socket.destroy(); + } + }); + + this.wss.on("connection", (ws) => { + const session = this.createSession(ws); + logger.info(`WS session connected: ${session.id}`); + + ws.on("message", (data) => { + const raw = data.toString(); + const msg = parseClientMessage(raw); + if (msg) { + this.handleClientMessage(session, msg); + } + }); + + ws.on("close", () => { + logger.info(`WS session disconnected: ${session.id}`); + this.cleanupSession(session); + if (this.onSessionDisconnect) { + this.onSessionDisconnect(session); + } + }); + + ws.on("error", (err) => { + logger.error(`WS session error: ${session.id}`, { error: err.message }); + }); + }); + } + + private createSession(ws: WebSocket): WsSession { + const id = `ws_${++this.sessionCounter}_${Date.now()}`; + const session: WsSession = { + id, + ws, + subscriptions: new Map(), + listenerIds: new Map(), + }; + this.sessions.set(id, session); + return session; + } + + private handleClientMessage(session: WsSession, msg: ClientMessage): void { + switch (msg.type) { + case "subscribe": + this.handleSubscribe(session, msg.tunnelId, msg.mode).catch((err) => { + logger.error("handleSubscribe failed", { sessionId: session.id, tunnelId: msg.tunnelId, error: err.message }); + }); + break; + case "unsubscribe": + this.handleUnsubscribe(session, msg.tunnelId); + break; + } + } + + private async handleSubscribe(session: WsSession, tunnelId: string, mode: SessionMode): Promise { + // Avoid duplicate subscriptions + if (session.subscriptions.has(tunnelId)) { + return; + } + + const listenerIds: string[] = []; + const manager = TunnelManager.getInstance(); + + try { + // Register stats listener + const [statsListenerId] = await manager.registerStatsListener(tunnelId, (_id, stats) => { + this.sendEvent(session, tunnelId, "stats", { stats }); + }); + listenerIds.push(`stats:${statsListenerId}`); + + // Register disconnect listener + const disconnectId = await manager.registerDisconnectListener(tunnelId, (_id, error, messages) => { + this.sendEvent(session, tunnelId, "disconnect", { error, messages }); + }); + listenerIds.push(`disconnect:${disconnectId}`); + + // Register stopped listener (fires on intentional stopTunnel(), not on network drops) + const stoppedId = await manager.registerStoppedListener(tunnelId, () => { + this.sendEvent(session, tunnelId, "stopped", {}); + }); + listenerIds.push(`stopped:${stoppedId}`); + + // Register reconnecting listener + const reconnectingId = await manager.registerReconnectingListener(tunnelId, (_id, retryCnt) => { + this.sendEvent(session, tunnelId, "reconnecting", { retryCnt }); + }); + listenerIds.push(`reconnecting:${reconnectingId}`); + + // Register reconnection completed listener + const reconnectedId = await manager.registerReconnectionCompletedListener(tunnelId, (_id, urls) => { + this.sendEvent(session, tunnelId, "reconnected", { urls }); + }); + listenerIds.push(`reconnected:${reconnectedId}`); + + // Register reconnection failed listener + const failedId = await manager.registerReconnectionFailedListener(tunnelId, (_id, retryCnt) => { + this.sendEvent(session, tunnelId, "reconnection_failed", { retryCnt }); + }); + listenerIds.push(`failed:${failedId}`); + + // Register will-reconnect listener + const willReconnectId = await manager.registerWillReconnectListener(tunnelId, (_id, error, messages) => { + this.sendEvent(session, tunnelId, "will_reconnect", { error, messages }); + }); + listenerIds.push(`will_reconnect:${willReconnectId}`); + + // Register worker error listener + const workerErrorId = await manager.registerWorkerErrorListner(tunnelId, (_id, error) => { + this.sendEvent(session, tunnelId, "worker_error", { message: error.message }); + }); + listenerIds.push(`worker_error:${workerErrorId}`); + + // Register start listener (for url_ready on reconnect) + const startId = await manager.registerStartListener(tunnelId, (_id, urls) => { + this.sendEvent(session, tunnelId, "url_ready", { urls }); + }); + listenerIds.push(`start:${startId}`); + + // All registrations succeeded. Commit subscription state atomically. + session.subscriptions.set(tunnelId, { tunnelId, mode }); + this.sessionTracker?.attach(tunnelId, session.id, mode); + session.listenerIds.set(tunnelId, listenerIds); + + this.sendEvent(session, tunnelId, "subscribed", { tunnelId }); + } catch (err) { + // Clean up any listeners registered before the failure + session.listenerIds.set(tunnelId, listenerIds); + this.deregisterListeners(session, tunnelId); + this.sendEvent(session, tunnelId, "error_response", { message: errorMessage(err) }); + } + } + + private handleUnsubscribe(session: WsSession, tunnelId: string): void { + this.deregisterListeners(session, tunnelId); + session.subscriptions.delete(tunnelId); + } + + private deregisterListeners(session: WsSession, tunnelId: string): void { + const ids = session.listenerIds.get(tunnelId); + if (!ids) return; + + const manager = TunnelManager.getInstance(); + for (const entry of ids) { + const [type, listenerId] = entry.split(":"); + try { + switch (type) { + case "stats": + manager.deregisterStatsListener(tunnelId, listenerId); + break; + case "disconnect": + manager.deregisterDisconnectListener(tunnelId, listenerId); + break; + case "stopped": + manager.deregisterStoppedListener(tunnelId, listenerId); + break; + case "reconnecting": + manager.deregisterReconnectingListener(tunnelId, listenerId); + break; + case "reconnected": + manager.deregisterReconnectionCompletedListener(tunnelId, listenerId); + break; + case "failed": + manager.deregisterReconnectionFailedListener(tunnelId, listenerId); + break; + case "will_reconnect": + manager.deregisterWillReconnectListener(tunnelId, listenerId); + break; + case "worker_error": + manager.deregisterWorkerErrorListener(tunnelId, listenerId); + break; + case "start": + // Start listener doesn't have a deregister (fire-once pattern) + break; + } + } catch { + // Tunnel may already be stopped + } + } + session.listenerIds.delete(tunnelId); + } + + private cleanupSession(session: WsSession): void { + // Deregister all listeners for this session + for (const tunnelId of session.subscriptions.keys()) { + this.deregisterListeners(session, tunnelId); + } + session.subscriptions.clear(); + this.sessions.delete(session.id); + } + + private sendEvent( + session: WsSession, + tunnelId: string, + event: T, + payload: TunnelEventPayloadMap[T] + ): void { + if (session.ws.readyState === WebSocket.OPEN) { + const msg = createTunnelEvent(tunnelId, event, payload); + session.ws.send(JSON.stringify(msg)); + } + } + + private readBody(req: http.IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + let size = 0; + const MAX_BODY = 1024 * 64; // 64KB limit + + req.on("data", (chunk: Buffer) => { + size += chunk.length; + if (size > MAX_BODY) { + req.destroy(); + reject(new Error("Request body too large")); + return; + } + chunks.push(chunk); + }); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); + } + + private sendJson(res: http.ServerResponse, statusCode: number, data: object): void { + const body = JSON.stringify(data); + res.writeHead(statusCode, { + "Content-Type": "application/json", + "Content-Length": Buffer.byteLength(body), + }); + res.end(body); + } + + /** + * Start listening on 127.0.0.1 with OS-assigned port. + * Returns the assigned port. + */ + listen(): Promise { + return new Promise((resolve, reject) => { + this.server.on("error", reject); + this.server.listen(0, "127.0.0.1", () => { + const addr = this.server.address() as { port: number }; + logger.info(`IPC server listening on 127.0.0.1:${addr.port}`); + resolve(addr.port); + }); + }); + } + + close(): Promise { + return new Promise((resolve) => { + // Close all WS connections + for (const session of this.sessions.values()) { + session.ws.close(1001, "Daemon shutting down"); + } + this.wss.close(); + this.server.close(() => resolve()); + }); + } +} diff --git a/src/daemon/lifecycle/daemonChild.ts b/src/daemon/lifecycle/daemonChild.ts new file mode 100644 index 0000000..b61cd42 --- /dev/null +++ b/src/daemon/lifecycle/daemonChild.ts @@ -0,0 +1,357 @@ +/** + * Daemon child process entry point. + * Called when the CLI is invoked with --_daemon-child. + * + * Responsibilities: + * 1. Redirect stdout/stderr to daemon.log + * 2. Start the IPC HTTP + WebSocket server + * 3. Write daemon.json (port + pid) atomically + * 4. Crash recovery: restore tunnels from daemon-state.json + * 5. Start all auto-start tunnels + * 6. Handle graceful shutdown (cleanup daemon.json + state file) + */ +import fs from "node:fs"; +import { IPCServer } from "../ipc/ipcServer.js"; +import { SessionTracker } from "../lifecycle/sessionTracker.js"; +import { + loadDaemonState, + persistDaemonState, + clearDaemonState, + addTunnelToState, + removeTunnelFromState, + DaemonState, + DaemonStateTunnel, +} from "../lifecycle/stateStore.js"; +import { TunnelManager, TunnelOrigin } from "../../tunnel_manager/TunnelManager.js"; +import { enablePackageLogging, logger, setLogLevel } from "../../logger.js"; +import { getDaemonInfoPath, getDaemonLogPath, ensurePinggyConfigDir, ensurePinggyLogDir } from "../../utils/configDir.js"; +import { detachAllTunnelLoggers } from "../../logger/tunnelLogger.js"; +import { getAutoStartConfigs, SavedTunnelConfig } from "../../cli/configStore.js"; +import { LogLevelName, readDaemonConfig } from "./daemonConfig.js"; +import { FinalConfig } from "../../types.js"; +import { errorMessage } from "../../utils/util.js"; +import { SessionMode } from "../ipc/ipcRoutes.js"; + +export interface DaemonInfo { + pid: number; + port: number; + startedAt: string; +} + +export interface DaemonHandle { + pid: number; + port: number; + shutdown: () => void; +} + +export interface RunDaemonOptions { + /** + * Install POSIX signal handlers (SIGTERM/SIGINT/exit/uncaught) that tear + * the daemon down and exit the process. Set to false when hosting the + * daemon inside another application that owns its own lifecycle. + * Default: true. + */ + installSignalHandlers?: boolean; + + /** + * Call process.exit(1) on startup failure. Set to false when hosting the + * daemon in-process so the caller can handle the error. + * Default: true. + */ + exitOnFailure?: boolean; +} + +// Module-level state for persistence +let daemonState: DaemonState = { tunnels: [], lastUpdated: "" }; + + +let sessionTrackerRef: SessionTracker | null = null; + +export function setDaemonSessionTracker(st: SessionTracker | null): void { + sessionTrackerRef = st; +} + +/** + * Write daemon.json atomically (write to tmp, then rename). + */ +function writeDaemonInfo(info: DaemonInfo): void { + ensurePinggyConfigDir(); + const infoPath = getDaemonInfoPath(); + const tmpPath = infoPath + ".tmp"; + fs.writeFileSync(tmpPath, JSON.stringify(info, null, 2), "utf-8"); + fs.renameSync(tmpPath, infoPath); +} + +/** + * Remove daemon.json on exit. + */ +export function removeDaemonInfo(): void { + try { + const infoPath = getDaemonInfoPath(); + if (fs.existsSync(infoPath)) fs.unlinkSync(infoPath); + } catch { + // Best effort + } +} + +/** + * Track a tunnel start in the persistent state. + */ +export function trackTunnelStart( + tunnelId: string, + configId: string, + name: string, + origin: TunnelOrigin, + config: FinalConfig, + mode: SessionMode, +): void { + const entry: DaemonStateTunnel = { + tunnelId, + configId, + name, + origin, + config, + mode, + startedAt: new Date().toISOString(), + }; + addTunnelToState(daemonState, entry); + persistDaemonState(daemonState); + + if (mode === SessionMode.Detached) { + sessionTrackerRef?.attach(tunnelId, "", SessionMode.Detached); + } +} + +/** + * Track a tunnel stop in the persistent state. + */ +export function trackTunnelStop(tunnelId: string): void { + removeTunnelFromState(daemonState, tunnelId); + persistDaemonState(daemonState); + sessionTrackerRef?.removeTunnel(tunnelId); +} + +export function trackIPCTunnelStart( + tunnelId: string, + origin: TunnelOrigin, + mode: SessionMode = SessionMode.Detached, +): void { + // Foreground tunnels die with the CLI that started them, so they have no + // business in the crash-recovery state file. + if (mode === SessionMode.Foreground) return; + const manager = TunnelManager.getInstance(); + const managed = manager.getManagedTunnel("", tunnelId); + if (!managed?.tunnelConfig) return; + trackTunnelStart( + tunnelId, + managed.configId, + managed.tunnelName ?? "", + origin, + managed.tunnelConfig as FinalConfig, + SessionMode.Detached, + ); +} + +/** + * Start a saved tunnel config in noTui mode. + */ +async function startSavedTunnel(saved: SavedTunnelConfig, manager: TunnelManager): Promise { + const config: FinalConfig = { + ...saved.tunnelConfig, + configId: saved.configId, + name: saved.name, + optional: { + ...saved.tunnelConfig.optional, + noTui: true, + }, + }; + + const tunnel = await manager.createTunnel(config, "cli"); + await manager.startTunnel(tunnel.tunnelid); + + const urls = await manager.getTunnelUrls(tunnel.tunnelid); + logger.info(`Tunnel "${saved.name}" started`, { tunnelId: tunnel.tunnelid, urls }); + + // Track in state for crash recovery + trackTunnelStart(tunnel.tunnelid, saved.configId, saved.name, "cli", saved.tunnelConfig, SessionMode.Detached); + + // Register reconnection listeners for resilience + await manager.registerWorkerErrorListner(tunnel.tunnelid, (_id, error) => { + logger.error(`[${saved.name}] Fatal error: ${error.message}`); + }); + + await manager.registerReconnectingListener(tunnel.tunnelid, (_id, retryCnt) => { + logger.info(`[${saved.name}] Reconnecting (attempt #${retryCnt})`); + }); + + await manager.registerReconnectionCompletedListener(tunnel.tunnelid, (_id, newUrls) => { + logger.info(`[${saved.name}] Reconnected`, { urls: newUrls }); + }); + + await manager.registerReconnectionFailedListener(tunnel.tunnelid, (_id, retryCnt) => { + logger.error(`[${saved.name}] Reconnection failed after ${retryCnt} attempts`); + }); +} + +/** + * Restore tunnels from crash recovery state. + * Only restores detached tunnels (foreground tunnels have no CLI to attach to). + */ +async function restoreCrashedTunnels(manager: TunnelManager): Promise { + const savedState = loadDaemonState(); + if (!savedState || savedState.tunnels.length === 0) return; + + const detachedTunnels = savedState.tunnels.filter(t => t.mode === "detached"); + if (detachedTunnels.length === 0) { + logger.info("Crash recovery: no detached tunnels to restore"); + clearDaemonState(); + return; + } + + logger.info(`Crash recovery: restoring ${detachedTunnels.length} detached tunnel(s)`); + + for (const entry of detachedTunnels) { + try { + const config = { + ...entry.config, + configId: entry.configId, + name: entry.name, + tunnelid: entry.tunnelId, // reuse stable ID for log file continuity + optional: { + ...entry.config.optional, + noTui: true, + }, + } as FinalConfig; + + const tunnel = await manager.createTunnel(config, entry.origin); + await manager.startTunnel(tunnel.tunnelid); + + const urls = await manager.getTunnelUrls(tunnel.tunnelid); + logger.info(`Restored tunnel "${entry.name}"`, { tunnelId: tunnel.tunnelid, urls }); + + // Track new tunnel ID in state + trackTunnelStart(tunnel.tunnelid, entry.configId, entry.name, entry.origin, entry.config, SessionMode.Detached); + + // Register resilience listeners + await manager.registerWorkerErrorListner(tunnel.tunnelid, (_id, error) => { + logger.error(`[${entry.name}] Fatal error: ${error.message}`); + }); + await manager.registerReconnectingListener(tunnel.tunnelid, (_id, retryCnt) => { + logger.info(`[${entry.name}] Reconnecting (attempt #${retryCnt})`); + }); + await manager.registerReconnectionCompletedListener(tunnel.tunnelid, (_id, newUrls) => { + logger.info(`[${entry.name}] Reconnected`, { urls: newUrls }); + }); + await manager.registerReconnectionFailedListener(tunnel.tunnelid, (_id, retryCnt) => { + logger.error(`[${entry.name}] Reconnection failed after ${retryCnt} attempts`); + }); + } catch (err) { + logger.error(`Failed to restore tunnel "${entry.name}"`, { error: errorMessage(err) }); + } + } +} + +export async function runDaemonChild(opts: RunDaemonOptions = {}): Promise { + const installSignalHandlers = opts.installSignalHandlers ?? true; + const exitOnFailure = opts.exitOnFailure ?? true; + + ensurePinggyConfigDir(); + + // Configure logging to daemon log file. Persisted config wins over the + // env var so `pinggy log level ` survives a daemon restart. + const persistedLevel = readDaemonConfig()?.logLevel; + const envLevel = process.env.PINGGY_LOG_LEVEL as LogLevelName | undefined; + const initialLevel: LogLevelName = persistedLevel ?? envLevel ?? "info"; + setLogLevel(initialLevel); + ensurePinggyLogDir(); + const logPath = getDaemonLogPath(); + enablePackageLogging({ + level: initialLevel, + filePath: logPath, + stdout: false, + enableSdkLog: true, + }); + + logger.info("Daemon starting", { pid: process.pid, inProcess: !installSignalHandlers }); + + const manager = TunnelManager.getInstance(); + const ipcServer = new IPCServer(); + const sessionTracker = new SessionTracker(); + + // Wire session tracker to IPC server + ipcServer.setOnSessionDisconnect((session) => { + sessionTracker.onSessionDisconnect(session); + }); + ipcServer.setSessionTracker(sessionTracker); + setDaemonSessionTracker(sessionTracker); + + let cleanedUp = false; + const cleanup = () => { + if (cleanedUp) return; + cleanedUp = true; + logger.info("Daemon shutting down"); + try { removeDaemonInfo(); } catch { } + try { clearDaemonState(); } catch { } + try { sessionTracker.destroy(); } catch { } + setDaemonSessionTracker(null); + try { detachAllTunnelLoggers(); } catch {} + try { manager.stopAllTunnels(); } catch { } + try { void ipcServer.close(); } catch { } + }; + + if (installSignalHandlers) { + process.on("SIGTERM", () => { cleanup(); process.exit(0); }); + process.on("SIGINT", () => { cleanup(); process.exit(0); }); + process.on("exit", cleanup); + + process.on("uncaughtException", (err) => { + logger.error("Daemon uncaught exception", { error: err.message, stack: err.stack }); + }); + process.on("unhandledRejection", (reason) => { + logger.error("Daemon unhandled rejection", { reason: String(reason) }); + }); + } + + try { + const port = await ipcServer.listen(); + + const info: DaemonInfo = { + pid: process.pid, + port, + startedAt: new Date().toISOString(), + }; + writeDaemonInfo(info); + logger.info("Daemon info written", info); + + await restoreCrashedTunnels(manager); + + const configs = getAutoStartConfigs(); + if (configs.length > 0) { + logger.info(`Starting ${configs.length} auto-start tunnel(s)`); + for (const saved of configs) { + try { + await startSavedTunnel(saved, manager); + } catch (err) { + logger.error(`Failed to start tunnel "${saved.name}"`, { error: errorMessage(err) }); + } + } + } else { + logger.info("No auto-start tunnels configured"); + } + + logger.info("Daemon ready", { pid: process.pid, port }); + + return { + pid: process.pid, + port, + shutdown: cleanup, + }; + } catch (err) { + logger.error("Daemon failed to start", { error: errorMessage(err) }); + removeDaemonInfo(); + if (exitOnFailure) { + process.exit(1); + } + throw err; + } +} diff --git a/src/daemon/lifecycle/daemonConfig.ts b/src/daemon/lifecycle/daemonConfig.ts new file mode 100644 index 0000000..ae12a9f --- /dev/null +++ b/src/daemon/lifecycle/daemonConfig.ts @@ -0,0 +1,41 @@ +/** + * Daemon configuration persisted across clean shutdowns. + * Separate from daemon-state.json (which is per-run crash-recovery state and + * is deleted on graceful shutdown) so settings like the chosen log level + * survive a daemon restart. + */ +import fs from "node:fs"; +import { ensurePinggyConfigDir, getDaemonConfigPath } from "../../utils/configDir.js"; + +export type LogLevelName = "debug" | "info" | "error"; + +export interface DaemonConfig { + logLevel?: LogLevelName; +} + +export function readDaemonConfig(): DaemonConfig | null { + try { + const p = getDaemonConfigPath(); + if (!fs.existsSync(p)) return null; + const raw = fs.readFileSync(p, "utf-8"); + const parsed = JSON.parse(raw) as DaemonConfig; + if (typeof parsed !== "object" || parsed === null) return null; + return parsed; + } catch { + return null; + } +} + +export function writeDaemonConfig(partial: Partial): void { + try { + ensurePinggyConfigDir(); + const current = readDaemonConfig() ?? {}; + const next: DaemonConfig = { ...current, ...partial }; + const p = getDaemonConfigPath(); + const tmp = p + ".tmp"; + fs.writeFileSync(tmp, JSON.stringify(next, null, 2), "utf-8"); + fs.renameSync(tmp, p); + } catch { + // Best-effort. Persistence failure should not break the daemon. + } +} diff --git a/src/daemon/lifecycle/daemonManager.ts b/src/daemon/lifecycle/daemonManager.ts new file mode 100644 index 0000000..57837b4 --- /dev/null +++ b/src/daemon/lifecycle/daemonManager.ts @@ -0,0 +1,263 @@ +/** + * Daemon lifecycle management. + * Handles spawning, stopping, status checking, and stale PID cleanup. + */ +import os from "node:os"; +import fs from "node:fs"; +import { spawn } from "node:child_process"; +import { getDaemonInfoPath, getDaemonLogPath } from "../../utils/configDir.js"; +import { DaemonInfo, DaemonHandle } from "./daemonChild.js"; +import { logger } from "../../logger.js"; +import { ClientOrigin } from "../ipc/ipcClient.js"; +import { TunnelStateType } from "../../types.js"; +import { errorMessage, getLocalAddress } from "../../utils/util.js"; + +let inProcessHandle: DaemonHandle | null = null; + +const DAEMON_SPAWN_TIMEOUT_MS = 8000; +const DAEMON_POLL_INTERVAL_MS = 200; + +/** + * Check if a process with the given PID is alive. + */ +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); // Signal 0: doesn't kill, just checks + return true; + } catch { + return false; + } +} + +/** + * Read and validate daemon.json. + * Returns null if file doesn't exist, is malformed, or PID is stale. + * Automatically cleans up stale daemon.json. + */ +export function getDaemonInfo(): DaemonInfo | null { + const infoPath = getDaemonInfoPath(); + if (!fs.existsSync(infoPath)) return null; + + try { + const data = JSON.parse(fs.readFileSync(infoPath, "utf-8")) as DaemonInfo; + if (!data.pid || !data.port) return null; + + // Validate PID is still alive + if (!isProcessAlive(data.pid)) { + logger.info("Stale daemon.json found, cleaning up", { stalePid: data.pid }); + try { fs.unlinkSync(infoPath); } catch { /* best effort */ } + return null; + } + + return data; + } catch { + return null; + } +} + +/** + * Check if the daemon is currently running. + */ +export function isDaemonRunning(): boolean { + return getDaemonInfo() !== null; +} + +function getDaemonSpawnArgs(): { command: string; args: string[]; env: NodeJS.ProcessEnv } { + return { + command: process.execPath, + args: [process.argv[1], "--_daemon-child"], + env: { ...process.env }, + }; +} + +/** + * Start the daemon by forking a detached child process. + * Waits for daemon.json to appear (confirms the child is ready). + */ +export async function startDaemon(): Promise { + const existing = getDaemonInfo(); + if (existing) { + return existing; + } + + const { command, args, env } = getDaemonSpawnArgs(); + logger.info("Spawning daemon child", { command, args }); + + let stderrOutput = ""; + let exited = false; + let exitCode: number | null = null; + + const child = spawn(command, args, { + detached: true, + stdio: ["ignore", "ignore", "pipe"], + env, + ...(os.platform() === "win32" ? { windowsHide: true } : {}), + }); + + child.stderr?.on("data", (chunk: Buffer) => { + stderrOutput += chunk.toString("utf-8"); + }); + child.on("exit", (code) => { + exited = true; + exitCode = code; + }); + child.unref(); + + const info = await pollForDaemonInfo(DAEMON_SPAWN_TIMEOUT_MS, () => exited); + if (!info) { + const logPath = getDaemonLogPath(); + if (exited) { + const detail = stderrOutput.trim() || `Check ${logPath} for details.`; + throw new Error(`Daemon child exited with code ${exitCode}. ${detail}`); + } + throw new Error(`Daemon failed to start within timeout. Check ${logPath} for details.`); + } + + // Detach stderr now that daemon is running + child.stderr?.removeAllListeners(); + child.stderr?.destroy(); + return info; +} + +/** + * Ensure a daemon is reachable, starting one if necessary. + * + * - If a daemon is already recorded in daemon.json and its PID is alive, returns its info. + * - Inside Electron (`process.versions.electron`), starts the daemon in-process via + * runDaemonChild(). The host retrieve the handle with + * getInProcessDaemonHandle() to shut it down on app quit. + * - Otherwise, spawns a detached daemon child. + */ +export async function ensureDaemonRunning(): Promise { + const existing = getDaemonInfo(); + if (existing) return existing; + + if (process.versions.electron) { + const { runDaemonChild } = await import("./daemonChild.js"); + const handle = await runDaemonChild({ + installSignalHandlers: false, + exitOnFailure: false, + }); + inProcessHandle = handle; + return ( + getDaemonInfo() ?? { + pid: handle.pid, + port: handle.port, + startedAt: new Date().toISOString(), + } + ); + } + + return startDaemon(); +} + +export function getInProcessDaemonHandle(): DaemonHandle | null { + return inProcessHandle; +} + +export interface ActiveTunnelSummary { + tunnelId: string; + name: string; + localAddress: string; + urls: string[]; +} + +/** + * Snapshot of tunnels currently running in the daemon. + * queries the daemon over HTTP via GET /tunnels. + */ +export async function getActiveTunnelSummaries(origin: ClientOrigin = "app"): Promise { + const info = getDaemonInfo(); + if (!info) return []; + + const { IPCClient } = await import("../ipc/ipcClient.js"); + const client = new IPCClient(info.port, origin); + let tunnels; + try { + tunnels = await client.listTunnels(); + } catch (err) { + logger.warn("Failed to list tunnels from daemon", { error: errorMessage(err) }); + return []; + } + if (!Array.isArray(tunnels)) return []; + + return tunnels + .filter(t => { + const state = t?.status?.state; + return state !== TunnelStateType.Closed && state !== TunnelStateType.Exited; + }) + .map(t => ({ + tunnelId: t.tunnelid, + name: t.tunnelconfig?.name || t.tunnelid.slice(0, 8), + localAddress: getLocalAddress(t.tunnelconfig), + urls: t.remoteurls ?? [], + })); +} + +/** + * Poll for daemon.json to appear on disk. + */ +function pollForDaemonInfo(timeoutMs: number, hasExited?: () => boolean): Promise { + return new Promise((resolve) => { + const start = Date.now(); + const check = () => { + const info = getDaemonInfo(); + if (info) { + resolve(info); + return; + } + // If the child already exited, no point waiting further + if (hasExited?.()) { + resolve(null); + return; + } + if (Date.now() - start > timeoutMs) { + resolve(null); + return; + } + setTimeout(check, DAEMON_POLL_INTERVAL_MS); + }; + check(); + }); +} + +export type StopDaemonResult = + | { ok: true } + | { ok: false; error: string }; + + +export async function stopDaemon(): Promise { + const info = getDaemonInfo(); + if (!info) return { ok: false, error: "No daemon is running." }; + + let daemonErrors: string[] = []; + try { + const { IPCClient } = await import("../ipc/ipcClient.js"); + const client = new IPCClient(info.port); + const result = await client.shutdown(); + logger.debug("Sent shutdown command to daemon", { result }); + if (Array.isArray(result?.errors)) daemonErrors = result.errors; + } catch (e) { + return { ok: false, error: `Failed to reach daemon: ${errorMessage(e)}` }; + } + + const exited = await waitForExit(info.pid, 5000); + if (!exited) { + const detail = daemonErrors.length > 0 ? ` Daemon reported: ${daemonErrors.join("; ")}` : ""; + return { ok: false, error: `Daemon PID ${info.pid} did not exit within 5s.${detail}` }; + } + + if (daemonErrors.length > 0) { + return { ok: false, error: `Daemon exited but reported errors: ${daemonErrors.join("; ")}` }; + } + return { ok: true }; +} + +async function waitForExit(pid: number, timeoutMs: number): Promise { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) return true; + await new Promise((r) => setTimeout(r, 100)); + } + return !isProcessAlive(pid); +} diff --git a/src/daemon/lifecycle/serviceInstaller.ts b/src/daemon/lifecycle/serviceInstaller.ts new file mode 100644 index 0000000..a6b2f68 --- /dev/null +++ b/src/daemon/lifecycle/serviceInstaller.ts @@ -0,0 +1,307 @@ +/** + * Platform-specific system service installer for the Pinggy daemon. + * Supports: systemd (Linux), launchd (macOS), Task Scheduler (Windows). + */ +import os from "node:os"; +import fs from "node:fs"; +import path from "node:path"; +import { execSync, execFileSync } from "node:child_process"; +import { logger } from "../../logger.js"; + +const SERVICE_LABEL = "io.pinggy.agent"; +const SYSTEMD_SERVICE_NAME = "pinggy"; + +interface ResolvedBinary { + /** The executable (node or the pinggy binary itself) */ + program: string; + /** Additional args before the daemon flag (e.g. the script path for node) */ + args: string[]; +} + +/** + * Resolve the absolute path to the pinggy binary. + * Returns structured data so each installer can quote/escape correctly. + * - pkg binary: program is the binary itself, no extra args + * - npm install: find via `which pinggy` + * - fallback: node + script path as separate tokens + */ +function resolveBinary(): ResolvedBinary { + const isPkg = "pkg" in process; + if (isPkg) return { program: process.execPath, args: [] }; + + try { + const bin = execSync(os.platform() === "win32" ? "where pinggy" : "which pinggy", { + encoding: "utf-8", + }).trim().split(/\r?\n/)[0]; + return { program: bin, args: [] }; + } catch { + // Fallback to node + script as separate tokens + return { program: process.execPath, args: [process.argv[1]] }; + } +} + +// Linux: systemd user service + +function getSystemdServicePath(): string { + const dir = path.join(os.homedir(), ".config", "systemd", "user"); + return path.join(dir, `${SYSTEMD_SERVICE_NAME}.service`); +} + +function generateSystemdUnit(bin: ResolvedBinary): string { + // Use --_daemon-child directly so systemd manages the long-running process. + // Type=simple: systemd tracks the ExecStart process itself (no forking). + const execStart = [bin.program, ...bin.args, "--_daemon-child"].join(" "); + return `[Unit] +Description=Pinggy Tunnel Daemon +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +ExecStart=${execStart} +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=default.target +`; +} + +function installSystemd(): void { + const bin = resolveBinary(); + const servicePath = getSystemdServicePath(); + const dir = path.dirname(servicePath); + + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(servicePath, generateSystemdUnit(bin), "utf-8"); + + execSync("systemctl --user daemon-reload"); + execSync(`systemctl --user enable ${SYSTEMD_SERVICE_NAME}`); + execSync(`systemctl --user start ${SYSTEMD_SERVICE_NAME}`); + logger.info("systemd user service installed and started", { servicePath }); + console.log(`Service installed: ${servicePath}`); + console.log(`Enable at boot: loginctl enable-linger ${os.userInfo().username}`); +} + +function uninstallSystemd(): void { + const servicePath = getSystemdServicePath(); + try { + execSync(`systemctl --user stop ${SYSTEMD_SERVICE_NAME} 2>/dev/null || true`); + execSync(`systemctl --user disable ${SYSTEMD_SERVICE_NAME} 2>/dev/null || true`); + } catch { /* ignore */ } + + if (fs.existsSync(servicePath)) { + fs.unlinkSync(servicePath); + execSync("systemctl --user daemon-reload"); + console.log(`Service removed: ${servicePath}`); + } else { + console.log("No systemd service found to remove."); + } +} + +// macOS: launchd LaunchAgent + +function getLaunchdPlistPath(): string { + return path.join(os.homedir(), "Library", "LaunchAgents", `${SERVICE_LABEL}.plist`); +} + +function generateLaunchdPlist(bin: ResolvedBinary): string { + // Use --_daemon-child directly so launchd manages the long-running process + const allArgs = [bin.program, ...bin.args, "--_daemon-child"]; + const programArgs = allArgs.map((p) => ` ${p}`).join("\n"); + + return ` + + + + Label + ${SERVICE_LABEL} + ProgramArguments + +${programArgs} + + RunAtLoad + + KeepAlive + + + +`; +} + +function installLaunchd(): void { + const bin = resolveBinary(); + const plistPath = getLaunchdPlistPath(); + const dir = path.dirname(plistPath); + + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(plistPath, generateLaunchdPlist(bin), "utf-8"); + + const uid = process.getuid?.() ?? 501; + + // Remove any existing registration first (re-install case) + try { + execSync(`launchctl bootout gui/${uid}/${SERVICE_LABEL} 2>/dev/null || true`); + } catch { /* not registered yet — fine */ } + + try { + // Modern macOS: bootstrap into the user domain + execSync(`launchctl bootstrap gui/${uid} ${plistPath}`); + } catch { + try { + // Fallback for older macOS + execSync(`launchctl load -w ${plistPath}`); + } catch { /* may already be loaded */ } + } + + logger.info("launchd agent installed and started", { plistPath }); + console.log(`Service installed and started: ${plistPath}`); +} + +function uninstallLaunchd(): void { + const plistPath = getLaunchdPlistPath(); + const uid = process.getuid?.() ?? 501; + + try { + execSync(`launchctl bootout gui/${uid}/${SERVICE_LABEL} 2>/dev/null || true`); + } catch { + try { + execSync(`launchctl unload ${plistPath} 2>/dev/null || true`); + } catch { /* ignore */ } + } + + if (fs.existsSync(plistPath)) { + fs.unlinkSync(plistPath); + console.log(`Service removed: ${plistPath}`); + } else { + console.log("No launchd agent found to remove."); + } +} + +// Windows: Task Scheduler XML (no admin required, hidden) + +const WINDOWS_TASK_NAME = "PinggyDaemon"; + +function generateTaskXml(bin: ResolvedBinary): string { + const command = bin.program; + const args = [...bin.args, "--_daemon-child"].join(" "); + + return ` + + + + true + + + + + InteractiveToken + LeastPrivilege + + + + true + false + false + PT0S + IgnoreNew + + PT1M + 3 + + + + + ${command}${args ? `\n ${args}` : ""} + + +`; +} + +function installWindows(): void { + const bin = resolveBinary(); + const xml = generateTaskXml(bin); + + // Task Scheduler XML must be UTF-16 LE with BOM + const tmpPath = path.join(os.tmpdir(), `${WINDOWS_TASK_NAME}.xml`); + fs.writeFileSync(tmpPath, "\ufeff" + xml, "utf16le"); + + try { + execFileSync("schtasks", [ + "/Create", "/TN", WINDOWS_TASK_NAME, "/XML", tmpPath, "/F", + ], { stdio: "inherit" }); + } finally { + // Clean up temp file regardless of success/failure + try { fs.unlinkSync(tmpPath); } catch { /* best effort */ } + } + + // Start the task immediately + execFileSync("schtasks", ["/Run", "/TN", WINDOWS_TASK_NAME], { stdio: "inherit" }); + console.log(`Scheduled task "${WINDOWS_TASK_NAME}" created and started (runs at login, hidden).`); +} + +function uninstallWindows(): void { + try { + // Stop the running task first + execFileSync("schtasks", ["/End", "/TN", WINDOWS_TASK_NAME], { stdio: "ignore" }); + } catch { /* may not be running */ } + + try { + execFileSync("schtasks", ["/Delete", "/TN", WINDOWS_TASK_NAME, "/F"], { stdio: "inherit" }); + console.log(`Scheduled task "${WINDOWS_TASK_NAME}" removed.`); + } catch { + console.log("No scheduled task found to remove."); + } +} + +// Public API + +export function installService(): void { + const platform = os.platform(); + const bin = resolveBinary(); + + // Warn if using npm-installed binary (path may change on Node updates) + if (!("pkg" in process) && bin.program !== process.execPath) { + console.warn( + "Warning: Using npm-installed binary. The path may change if Node.js is updated.\n" + + "Consider using a standalone binary (pkg) for system services." + ); + } + + switch (platform) { + case "linux": + installSystemd(); + break; + case "darwin": + installLaunchd(); + break; + case "win32": + installWindows(); + break; + default: + console.error(`Unsupported platform: ${platform}`); + process.exit(1); + } +} + +export function uninstallService(): void { + const platform = os.platform(); + switch (platform) { + case "linux": + uninstallSystemd(); + break; + case "darwin": + uninstallLaunchd(); + break; + case "win32": + uninstallWindows(); + break; + default: + console.error(`Unsupported platform: ${platform}`); + process.exit(1); + } +} + + + diff --git a/src/daemon/lifecycle/sessionTracker.ts b/src/daemon/lifecycle/sessionTracker.ts new file mode 100644 index 0000000..1d15e0c --- /dev/null +++ b/src/daemon/lifecycle/sessionTracker.ts @@ -0,0 +1,156 @@ +/** + * Session Tracker for daemon tunnel ownership. + * Tracks which tunnels are "foreground-attached" vs "detached". + * Implements orphan cleanup: if a foreground tunnel's WS session drops, + * waits 5 seconds then auto-kills the tunnel. + */ +import { TunnelManager } from "../../tunnel_manager/TunnelManager.js"; +import { logger } from "../../logger.js"; +import { WsSession } from "../ipc/ipcServer.js"; +import { trackTunnelStop } from "./daemonChild.js"; +import { errorMessage } from "../../utils/util.js"; +import { SessionMode } from "../ipc/ipcRoutes.js"; + +export interface TunnelOwnership { + tunnelId: string; + sessionId: string; + mode: SessionMode; +} + +const GRACE_PERIOD_MS = 5000; + +export class SessionTracker { + private ownership: Map = new Map(); // tunnelId → ownership + private graceTimers: Map = new Map(); // tunnelId → timer + + /** + * Register a tunnel as owned by a session in a specific mode. + */ + attach(tunnelId: string, sessionId: string, mode: SessionMode): void { + // Cancel any pending grace timer for this tunnel + this.cancelGraceTimer(tunnelId); + + this.ownership.set(tunnelId, { tunnelId, sessionId, mode }); + logger.info(`Tunnel ${tunnelId} attached to session ${sessionId} (${mode})`); + } + + /** + * Mark a tunnel as detached (persists without session). + */ + markDetached(tunnelId: string): void { + const existing = this.ownership.get(tunnelId); + if (existing) { + existing.mode = SessionMode.Detached; + existing.sessionId = ""; + logger.info(`Tunnel ${tunnelId} marked as detached`); + } + } + + /** + * Get the ownership info for a tunnel. + */ + getOwnership(tunnelId: string): TunnelOwnership | undefined { + return this.ownership.get(tunnelId); + } + + /** + * Get all tunnels owned by a session. + */ + getTunnelsForSession(sessionId: string): TunnelOwnership[] { + return Array.from(this.ownership.values()).filter(o => o.sessionId === sessionId); + } + + /** + * Called when a WS session disconnects. + * Starts grace timers for all foreground tunnels owned by that session. + */ + onSessionDisconnect(session: WsSession): void { + const sessionId = session.id; + const tunnels = this.getTunnelsForSession(sessionId); + + for (const ownership of tunnels) { + if (ownership.mode === SessionMode.Foreground) { + logger.info(`Session ${sessionId} disconnected. Starting ${GRACE_PERIOD_MS}ms grace for tunnel ${ownership.tunnelId}`); + this.startGraceTimer(ownership.tunnelId); + } + // Detached tunnels are unaffected by session disconnect + } + } + + /** + * Called when a session re-attaches to a tunnel (e.g. CLI reconnects WS). + * Cancels any pending grace timer. + */ + onSessionReconnect(tunnelId: string, sessionId: string): void { + this.cancelGraceTimer(tunnelId); + const existing = this.ownership.get(tunnelId); + if (existing) { + existing.sessionId = sessionId; + logger.info(`Tunnel ${tunnelId} re-attached to session ${sessionId}`); + } + } + + /** + * Remove ownership tracking for a tunnel (called when tunnel stops). + */ + removeTunnel(tunnelId: string): void { + this.cancelGraceTimer(tunnelId); + this.ownership.delete(tunnelId); + } + + /** + * Check if a tunnel is in foreground mode. + */ + isForeground(tunnelId: string): boolean { + const o = this.ownership.get(tunnelId); + return o?.mode === SessionMode.Foreground; + } + + private startGraceTimer(tunnelId: string): void { + // Clear any existing timer + this.cancelGraceTimer(tunnelId); + + const timer = setTimeout(() => { + this.graceTimers.delete(tunnelId); + this.killOrphanedTunnel(tunnelId); + }, GRACE_PERIOD_MS); + + this.graceTimers.set(tunnelId, timer); + } + + private cancelGraceTimer(tunnelId: string): void { + const timer = this.graceTimers.get(tunnelId); + if (timer) { + clearTimeout(timer); + this.graceTimers.delete(tunnelId); + } + } + + private killOrphanedTunnel(tunnelId: string): void { + const ownership = this.ownership.get(tunnelId); + if (!ownership || ownership.mode !== SessionMode.Foreground) { + return; // Already detached or removed + } + + logger.info(`Grace period expired for tunnel ${tunnelId}. Stopping orphaned foreground tunnel.`); + try { + const manager = TunnelManager.getInstance(); + manager.stopTunnel(tunnelId); + } catch (err) { + logger.error(`Failed to stop orphaned tunnel ${tunnelId}`, { error: errorMessage(err) }); + } + trackTunnelStop(tunnelId); + this.ownership.delete(tunnelId); + } + + /** + * Cleanup all timers (for daemon shutdown). + */ + destroy(): void { + for (const timer of this.graceTimers.values()) { + clearTimeout(timer); + } + this.graceTimers.clear(); + this.ownership.clear(); + } +} diff --git a/src/daemon/lifecycle/stateStore.ts b/src/daemon/lifecycle/stateStore.ts new file mode 100644 index 0000000..39b23e0 --- /dev/null +++ b/src/daemon/lifecycle/stateStore.ts @@ -0,0 +1,118 @@ +/** + * Daemon state persistence for crash recovery. + * Writes active tunnel configs to daemon-state.json so the daemon + * can restore tunnels after an unexpected crash. + * + * Behavior: + * - On clean shutdown: state file is deleted (no recovery needed) + * - On crash: state file remains, daemon restores detached tunnels on restart + * - On reboot (OS service start): only autoStart configs are used (not state file) + */ +import fs from "node:fs"; +import path from "node:path"; +import { TunnelConfigurationV1 } from "@pinggy/pinggy"; +import { getPinggyConfigDir, ensurePinggyConfigDir } from "../../utils/configDir.js"; +import { logger } from "../../logger.js"; +import { TunnelOrigin } from "../../tunnel_manager/TunnelManager.js"; +import { errorMessage } from "../../utils/util.js"; +import { SessionMode } from "../ipc/ipcRoutes.js"; + +// Types + +export interface DaemonStateTunnel { + tunnelId: string; + configId: string; + name: string; + origin: TunnelOrigin; + config: TunnelConfigurationV1; + mode: SessionMode; + startedAt: string; +} + +export interface DaemonState { + tunnels: DaemonStateTunnel[]; + lastUpdated: string; +} + +// State Store + +const STATE_FILENAME = "daemon-state.json"; + +function getStatePath(): string { + return path.join(getPinggyConfigDir(), STATE_FILENAME); +} + +/** + * Load the daemon state from disk. + * Returns null if file doesn't exist or is malformed. + */ +export function loadDaemonState(): DaemonState | null { + const statePath = getStatePath(); + try { + if (!fs.existsSync(statePath)) return null; + const raw = fs.readFileSync(statePath, "utf-8"); + const state = JSON.parse(raw) as DaemonState; + if (!Array.isArray(state.tunnels)) return null; + return state; + } catch { + return null; + } +} + +/** + * Persist the current daemon state to disk. + * Called after every tunnel start/stop. + */ +export function persistDaemonState(state: DaemonState): void { + ensurePinggyConfigDir(); + const statePath = getStatePath(); + const tmpPath = statePath + ".tmp"; + try { + state.lastUpdated = new Date().toISOString(); + fs.writeFileSync(tmpPath, JSON.stringify(state, null, 2), "utf-8"); + fs.renameSync(tmpPath, statePath); + } catch (err) { + logger.error("Failed to persist daemon state", { error: errorMessage(err) }); + } +} + +/** + * Remove the state file (called on clean shutdown). + */ +export function clearDaemonState(): void { + const statePath = getStatePath(); + try { + if (fs.existsSync(statePath)) { + fs.unlinkSync(statePath); + } + } catch { + // Best effort + } +} + +/** + * Add a tunnel to the state. + */ +export function addTunnelToState( + state: DaemonState, + tunnel: DaemonStateTunnel +): void { + // Remove existing entry for same tunnelId if present + state.tunnels = state.tunnels.filter(t => t.tunnelId !== tunnel.tunnelId); + state.tunnels.push(tunnel); +} + +/** + * Remove a tunnel from the state. + */ +export function removeTunnelFromState(state: DaemonState, tunnelId: string): void { + state.tunnels = state.tunnels.filter(t => t.tunnelId !== tunnelId); +} + +/** + * Check if this is a crash recovery situation. + * True if: daemon-state.json exists (daemon didn't shut down cleanly). + */ +export function isCrashRecovery(): boolean { + return loadDaemonState() !== null; +} diff --git a/src/daemon/tunnelClient.ts b/src/daemon/tunnelClient.ts new file mode 100644 index 0000000..003b6f1 --- /dev/null +++ b/src/daemon/tunnelClient.ts @@ -0,0 +1,277 @@ +/** + * TunnelClient: public facade for tunnel operations via the daemon. + * Used by both CLI and App. Mirrors TunnelOperations interface but routes + * all operations through the daemon process via HTTP + WebSocket. + * + * Composes three helpers: + * - IPCClient : HTTP request/response (start, stop, list, restart, log, …) + * - WsStream : WebSocket lifecycle, per-tunnel subscriptions, event dispatch + * - DaemonHealth : heartbeat, reconnect on transient WS drops, daemon-lost events + */ +import { TunnelUsageType } from "@pinggy/pinggy"; +import { ClientOrigin, IPCClient } from "./ipc/ipcClient.js"; +import { ensureDaemonRunning } from "./lifecycle/daemonManager.js"; +import { TunnelResponse, TunnelResponseV2 } from "../remote_management/handler.js"; +import { TunnelConfig, TunnelConfigV1 } from "../remote_management/remote_schema.js"; +import { ErrorResponse, isErrorResponse } from "../types.js"; +import { + WsStream, + SubscriptionMode, + type StatsCallback, + type DisconnectCallback, + type ReconnectingCallback, + type ReconnectedCallback, + type ReconnectionFailedCallback, + type ErrorCallback, + type UrlReadyCallback, + type WorkerErrorCallback, + type WillReconnectCallback, + type StoppedCallback, +} from "./ws/wsStream.js"; +import { + DaemonHealth, + type DaemonLostCallback, + type DaemonReconnectingCallback, + type DaemonReconnectedCallback, +} from "./daemonHealth.js"; +import { DaemonTunnelHandler } from "./daemonTunnelHandler.js"; + +// Re-exports so external consumers don't need to know about the split. +export { DaemonTunnelHandler } from "./daemonTunnelHandler.js"; +export type { + StatsCallback, + DisconnectCallback, + ReconnectingCallback, + ReconnectedCallback, + ReconnectionFailedCallback, + ErrorCallback, + UrlReadyCallback, + WorkerErrorCallback, + WillReconnectCallback, + StoppedCallback, +} from "./ws/wsStream.js"; +export type { + DaemonLostReason, + DaemonLostCallback, + DaemonReconnectingCallback, + DaemonReconnectedCallback, +} from "./daemonHealth.js"; + +import { LogPathsResponse, ResolveLogPathResponse, SessionMode } from "./ipc/ipcRoutes.js"; + +export interface TunnelClientOptions { + origin?: ClientOrigin; +} + +export class TunnelClient { + private ipc: IPCClient | null = null; + private origin: ClientOrigin; + private stream: WsStream; + health: DaemonHealth; + + constructor(options: TunnelClientOptions = {}) { + this.origin = options.origin ?? "cli"; + this.stream = new WsStream(() => { + if (!this.ipc) throw new Error("TunnelClient not initialized. Call ensureDaemon() first."); + return this.ipc.getWsUrl(); + }); + this.health = new DaemonHealth(() => this.ipc, this.stream); + } + + + async ensureDaemon(): Promise { + const info = await ensureDaemonRunning(); + this.ipc = new IPCClient(info.port, this.origin); + this.health.bindPid(info.pid); + } + + static async forRemoteManagement(): Promise { + const client = new TunnelClient({ origin: "remote" }); + await client.ensureDaemon(); + // Remote management never opens the daemon WebSocket, so the WS-open + // path that normally starts the heartbeat never fires. Start it here. + client.health.startHeartbeat(); + return client; + } + + /** + * Close the WebSocket connection and cleanup. + */ + close(): void { + this.health.stopHeartbeat(); + this.stream.closeNormally(); + } + + async ping(): Promise<{ status: string; pid: number; uptime: number }> { + this.assertClient(); + return this.ipc!.ping(); + } + + // Tunnel Operations (HTTP) + + // Callers construct config from SDK shapes (FinalConfig) that are + // structurally compatible with the zod-derived wire type but not nominally + // identical. Accept the SDK shape and cast at the IPC boundary. + async handleStartV2(config: object, noWait?: boolean, mode?: SessionMode): Promise { + this.assertClient(); + return this.ipc!.startTunnelWithConfig(config as TunnelConfigV1, mode ?? SessionMode.Detached, noWait); + } + + async handleStart(config: object, noWait?: boolean, mode?: SessionMode): Promise { + this.assertClient(); + return this.ipc!.startTunnelV1(config as TunnelConfig, mode ?? SessionMode.Detached, noWait); + } + + async handleUpdateConfig(config: object, noWait?: boolean): Promise { + this.assertClient(); + return this.ipc!.updateConfig(config as TunnelConfig, noWait); + } + + async handleUpdateConfigV2(config: object, noWait?: boolean): Promise { + this.assertClient(); + return this.ipc!.updateConfigV2(config as TunnelConfigV1, noWait); + } + + async handleStop(tunnelId: string): Promise { + this.assertClient(); + return this.ipc!.stopTunnel(tunnelId); + } + + async handleListV2(): Promise { + this.assertClient(); + return this.ipc!.listTunnels(); + } + + async handleList(): Promise { + this.assertClient(); + return this.ipc!.listTunnelsV1(); + } + + handleRemoveStoppedTunnelByTunnelId(tunnelId: string): boolean | ErrorResponse { + this.assertClient(); + // Fire and forget: TunnelHandler requires sync return; daemon call is async. + void this.ipc!.removeStoppedTunnel({ tunnelid: tunnelId }); + return true; + } + + handleRemoveStoppedTunnelByConfigId(configId: string): boolean | ErrorResponse { + this.assertClient(); + void this.ipc!.removeStoppedTunnel({ configId }); + return true; + } + + async handleGet(tunnelId: string): Promise { + this.assertClient(); + return this.ipc!.getTunnel(tunnelId); + } + + async handleRestart(tunnelId: string): Promise { + this.assertClient(); + return this.ipc!.restartTunnel(tunnelId); + } + + async shutdown(): Promise { + this.assertClient(); + await this.ipc!.shutdown(); + this.close(); + } + + async getLogLevel(): Promise { + this.assertClient(); + const res = await this.ipc!.getLogLevel(); + return res.level; + } + + async setLogLevel(level: "debug" | "info" | "error"): Promise { + this.assertClient(); + await this.ipc!.setLogLevel(level); + } + + async getTunnelLogging(): Promise { + this.assertClient(); + const res = await this.ipc!.getTunnelLogging(); + return res.enabled; + } + + async setTunnelLogging(enabled: boolean): Promise { + this.assertClient(); + await this.ipc!.setTunnelLogging(enabled); + } + + async getLogPaths(): Promise { + this.assertClient(); + return await this.ipc!.getLogPaths() as LogPathsResponse; + } + + async resolveLogPath(q: string): Promise { + this.assertClient(); + return await this.ipc!.resolveLogPath(q) as ResolveLogPathResponse; + } + + async restart(tunnelId: string): Promise { + this.assertClient(); + await this.ipc!.restartTunnel(tunnelId); + } + + // Streaming — delegate to WsStream, guarded by daemon-lost + + async attach(tunnelId: string, mode: SubscriptionMode = SessionMode.Foreground): Promise { + if (this.health.isLost()) return; + await this.stream.subscribe(tunnelId, mode); + } + + detach(tunnelId: string): void { + const wasSubscribed = this.stream.unsubscribe(tunnelId); + if (!wasSubscribed) return; + if (!this.stream.hasSubscriptions()) this.close(); + } + + // Event registration — delegate to WsStream + + onStats(cb: StatsCallback): void { this.stream.onStats(cb); } + onDisconnect(cb: DisconnectCallback): void { this.stream.onDisconnect(cb); } + onReconnecting(cb: ReconnectingCallback): void { this.stream.onReconnecting(cb); } + onReconnected(cb: ReconnectedCallback): void { this.stream.onReconnected(cb); } + onReconnectionFailed(cb: ReconnectionFailedCallback): void { this.stream.onReconnectionFailed(cb); } + onError(cb: ErrorCallback): void { this.stream.onError(cb); } + onUrlReady(cb: UrlReadyCallback): void { this.stream.onUrlReady(cb); } + onWorkerError(cb: WorkerErrorCallback): void { this.stream.onWorkerError(cb); } + onWillReconnect(cb: WillReconnectCallback): void { this.stream.onWillReconnect(cb); } + onStopped(cb: StoppedCallback): void { this.stream.onStopped(cb); } + + // Daemon-loss events — delegate to DaemonHealth + + onDaemonLost(cb: DaemonLostCallback): void { this.health.onLost(cb); } + onDaemonReconnecting(cb: DaemonReconnectingCallback): void { this.health.onReconnecting(cb); } + onDaemonReconnected(cb: DaemonReconnectedCallback): void { this.health.onReconnected(cb); } + isDaemonLost(): boolean { return this.health.isLost(); } + + // App-compat shims (register listener + auto-attach in detached mode) + + handleRegisterStatsListener(tunnelId: string, listener: (tunnelId: string, stats: TunnelUsageType) => void): void { + this.onStats(listener); + this.attach(tunnelId, "detached").catch(() => {}); + } + + handleRegisterDisconnectListener(tunnelId: string, listener: (tunnelId: string, error: string, messages: string[]) => void): void { + this.onDisconnect(listener); + this.attach(tunnelId, "detached").catch(() => {}); + } + + handleUnregisterStatsListener(_tunnelId: string, _listenerId: string): void { + // No-op in daemon mode; stats flow over WS push, not registered listeners. + } + + async handleGetTunnelStats(tunnelId: string): Promise { + this.assertClient(); + return this.ipc!.getTunnelStats(tunnelId); + } + + // Private + + private assertClient(): void { + if (!this.ipc) { + throw new Error("TunnelClient not initialized. Call ensureDaemon() first."); + } + } +} diff --git a/src/daemon/ws/wsProtocol.ts b/src/daemon/ws/wsProtocol.ts new file mode 100644 index 0000000..73b9b34 --- /dev/null +++ b/src/daemon/ws/wsProtocol.ts @@ -0,0 +1,92 @@ +/** + * WebSocket event protocol for daemon ↔ CLI/App communication. + * Defines message types for bidirectional streaming over the IPC WebSocket. + */ +import { TunnelUsageType } from "@pinggy/pinggy"; +import { SessionMode } from "../ipc/ipcRoutes.js"; + +// Client → Daemon Messages + +export type ClientMessageType = "subscribe" | "unsubscribe"; + +export interface SubscribeMessage { + type: "subscribe"; + tunnelId: string; + mode: SessionMode; +} + +export interface UnsubscribeMessage { + type: "unsubscribe"; + tunnelId: string; +} + +export type ClientMessage = SubscribeMessage | UnsubscribeMessage; + +// Daemon → Client Messages + +export type DaemonEventType = + | "url_ready" + | "stats" + | "disconnect" + | "reconnecting" + | "reconnected" + | "reconnection_failed" + | "error" + | "stopped" + | "will_reconnect" + | "worker_error" + | "subscribed" + | "error_response"; + +export interface TunnelEvent { + type: "tunnel_event"; + tunnelId: string; + event: T; + payload: TunnelEventPayloadMap[T]; +} + +export interface TunnelEventPayloadMap { + url_ready: { urls: string[] }; + stats: { stats: TunnelUsageType }; + disconnect: { error: string; messages: string[] }; + reconnecting: { retryCnt: number }; + reconnected: { urls: string[] }; + reconnection_failed: { retryCnt: number }; + error: { message: string; isFatal: boolean }; + stopped: {}; + will_reconnect: { error: string; messages: string[] }; + worker_error: { message: string }; + subscribed: { tunnelId: string }; + error_response: { message: string }; +} + +export type DaemonMessage = TunnelEvent; + +// Helpers + +export function createTunnelEvent( + tunnelId: string, + event: T, + payload: TunnelEventPayloadMap[T] +): TunnelEvent { + return { type: "tunnel_event", tunnelId, event, payload }; +} + +export function parseClientMessage(raw: string): ClientMessage | null { + try { + const msg = JSON.parse(raw); + if (msg.type === "subscribe" && msg.tunnelId) { + return { + type: "subscribe", + tunnelId: msg.tunnelId, + mode: msg.mode === SessionMode.Detached ? SessionMode.Detached : SessionMode.Foreground, + }; + } + if (msg.type === "unsubscribe" && msg.tunnelId) { + return { type: "unsubscribe", tunnelId: msg.tunnelId }; + } + return null; + } catch { + return null; + } +} diff --git a/src/daemon/ws/wsStream.ts b/src/daemon/ws/wsStream.ts new file mode 100644 index 0000000..2b7263c --- /dev/null +++ b/src/daemon/ws/wsStream.ts @@ -0,0 +1,241 @@ +/** + * WsStream: owns the WebSocket connection to the daemon, tracks per-tunnel + * subscriptions, and dispatches tunnel events to registered callbacks. + * + * Lifecycle decisions (reconnect, daemon-loss, heartbeat) live elsewhere. + * This class only cares about "is the socket open and what should I do with + * the bytes I receive". Consumers wire onOpen/onClose to drive policy. + */ +import { WebSocket } from "ws"; +import { TunnelUsageType } from "@pinggy/pinggy"; +import { logger } from "../../logger.js"; +import { + ClientMessage, + TunnelEvent, + TunnelEventPayloadMap, +} from "./wsProtocol.js"; +import { errorMessage } from "../../utils/util.js"; +import { SessionMode } from "../ipc/ipcRoutes.js"; + +// Public callback types — re-exported by tunnelClient.ts for external consumers. +export type StatsCallback = (tunnelId: string, stats: TunnelUsageType) => void; +export type DisconnectCallback = (tunnelId: string, error: string, messages: string[]) => void; +export type ReconnectingCallback = (tunnelId: string, retryCnt: number) => void; +export type ReconnectedCallback = (tunnelId: string, urls: string[]) => void; +export type ReconnectionFailedCallback = (tunnelId: string, retryCnt: number) => void; +export type ErrorCallback = (tunnelId: string, message: string, isFatal: boolean) => void; +export type UrlReadyCallback = (tunnelId: string, urls: string[]) => void; +export type WorkerErrorCallback = (tunnelId: string, message: string) => void; +export type WillReconnectCallback = (tunnelId: string, error: string, messages: string[]) => void; +export type StoppedCallback = (tunnelId: string) => void; + +export type SubscriptionMode = SessionMode; +export type SubscriptionInfo = { mode: SubscriptionMode }; + +interface EventCallbacks { + stats: StatsCallback[]; + disconnect: DisconnectCallback[]; + reconnecting: ReconnectingCallback[]; + reconnected: ReconnectedCallback[]; + reconnection_failed: ReconnectionFailedCallback[]; + error: ErrorCallback[]; + url_ready: UrlReadyCallback[]; + worker_error: WorkerErrorCallback[]; + will_reconnect: WillReconnectCallback[]; + stopped: StoppedCallback[]; +} + +export const WS_NORMAL_CLOSE = 1000; + +export class WsStream { + private ws: WebSocket | null = null; + private wsReady: Promise | null = null; + private wsResolve: (() => void) | null = null; + private subscribedTunnels: Map = new Map(); + private callbacks: EventCallbacks = { + stats: [], disconnect: [], reconnecting: [], reconnected: [], + reconnection_failed: [], error: [], url_ready: [], worker_error: [], + will_reconnect: [], stopped: [], + }; + private openListeners: Array<() => void> = []; + private closeListeners: Array<(code: number) => void> = []; + + constructor(private getWsUrl: () => string) {} + + // Lifecycle + + async ensureOpen(): Promise { + if (this.ws && this.ws.readyState === WebSocket.OPEN) return; + + this.wsReady = new Promise((resolve) => { this.wsResolve = resolve; }); + this.ws = new WebSocket(this.getWsUrl()); + + this.ws.on("open", () => { + if (this.wsResolve) { this.wsResolve(); this.wsResolve = null; } + for (const cb of this.openListeners) { + try { cb(); } catch (err) { logger.debug("WsStream open listener threw", { error: errorMessage(err) }); } + } + }); + + this.ws.on("message", (data) => this.handleMessage(data.toString())); + + this.ws.on("close", (code: number) => { + this.ws = null; + this.wsReady = null; + this.wsResolve = null; + for (const cb of this.closeListeners) { + try { cb(code); } catch (err) { logger.debug("WsStream close listener threw", { error: errorMessage(err) }); } + } + }); + + this.ws.on("error", (err) => { + logger.debug("WsStream WS error", { error: errorMessage(err) }); + }); + + await this.wsReady; + } + + /** Send a normal close (1000). Use for user-initiated shutdown. */ + closeNormally(): void { + if (!this.ws) return; + try { this.ws.close(WS_NORMAL_CLOSE, "Client closing"); } catch { /* socket may already be dead */ } + this.ws = null; + this.wsReady = null; + this.wsResolve = null; + this.subscribedTunnels.clear(); + } + + /** Hard kill the socket. Use when daemon is known dead. */ + terminate(): void { + if (!this.ws) return; + try { this.ws.terminate(); } catch { /* ignore */ } + this.ws = null; + this.wsReady = null; + this.wsResolve = null; + this.subscribedTunnels.clear(); + } + + isOpen(): boolean { + return !!this.ws && this.ws.readyState === WebSocket.OPEN; + } + + // Subscriptions + + async subscribe(tunnelId: string, mode: SubscriptionMode = SessionMode.Foreground): Promise { + await this.ensureOpen(); + if (this.subscribedTunnels.has(tunnelId)) return; + const msg: ClientMessage = { type: "subscribe", tunnelId, mode }; + this.ws!.send(JSON.stringify(msg)); + this.subscribedTunnels.set(tunnelId, { mode }); + } + + /** + * Remove the local subscription. Sends an unsubscribe frame if the socket + * is still open; if not, the daemon cleans up server-side when the session + * closes. Returns true if the caller had this subscription. + */ + unsubscribe(tunnelId: string): boolean { + const wasSubscribed = this.subscribedTunnels.delete(tunnelId); + if (!wasSubscribed) return false; + + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + try { + const msg: ClientMessage = { type: "unsubscribe", tunnelId }; + this.ws.send(JSON.stringify(msg)); + } catch { /* socket died between check and send */ } + } + return true; + } + + hasSubscriptions(): boolean { return this.subscribedTunnels.size > 0; } + subscriptionCount(): number { return this.subscribedTunnels.size; } + + /** Snapshot of current subscriptions, for the reconnect path to replay. */ + snapshotSubscriptions(): Array<[string, SubscriptionInfo]> { + return Array.from(this.subscribedTunnels.entries()); + } + + /** + * Re-open the WS (fresh socket) and re-subscribe a previously captured set. + * Clears existing state first so ensureOpen builds a new connection. + */ + async restoreSubscriptions(snapshot: Array<[string, SubscriptionInfo]>): Promise { + this.subscribedTunnels.clear(); + this.ws = null; + this.wsReady = null; + this.wsResolve = null; + + await this.ensureOpen(); + + for (const [tunnelId, info] of snapshot) { + const msg: ClientMessage = { type: "subscribe", tunnelId, mode: info.mode }; + this.ws!.send(JSON.stringify(msg)); + this.subscribedTunnels.set(tunnelId, info); + } + } + + // Event registration + + onOpen(cb: () => void): void { this.openListeners.push(cb); } + onClose(cb: (code: number) => void): void { this.closeListeners.push(cb); } + + onStats(cb: StatsCallback): void { this.callbacks.stats.push(cb); } + onDisconnect(cb: DisconnectCallback): void { this.callbacks.disconnect.push(cb); } + onReconnecting(cb: ReconnectingCallback): void { this.callbacks.reconnecting.push(cb); } + onReconnected(cb: ReconnectedCallback): void { this.callbacks.reconnected.push(cb); } + onReconnectionFailed(cb: ReconnectionFailedCallback): void { this.callbacks.reconnection_failed.push(cb); } + onError(cb: ErrorCallback): void { this.callbacks.error.push(cb); } + onUrlReady(cb: UrlReadyCallback): void { this.callbacks.url_ready.push(cb); } + onWorkerError(cb: WorkerErrorCallback): void { this.callbacks.worker_error.push(cb); } + onWillReconnect(cb: WillReconnectCallback): void { this.callbacks.will_reconnect.push(cb); } + onStopped(cb: StoppedCallback): void { this.callbacks.stopped.push(cb); } + + // Private + + private handleMessage(raw: string): void { + let msg: TunnelEvent; + try { msg = JSON.parse(raw) as TunnelEvent; } catch { return; } + if (msg.type !== "tunnel_event") return; + + const { tunnelId, event, payload } = msg; + + switch (event) { + case "stats": + for (const cb of this.callbacks.stats) cb(tunnelId, (payload as TunnelEventPayloadMap["stats"]).stats); + break; + case "disconnect": { + const p = payload as TunnelEventPayloadMap["disconnect"]; + for (const cb of this.callbacks.disconnect) cb(tunnelId, p.error, p.messages); + break; + } + case "reconnecting": + for (const cb of this.callbacks.reconnecting) cb(tunnelId, (payload as TunnelEventPayloadMap["reconnecting"]).retryCnt); + break; + case "reconnected": + for (const cb of this.callbacks.reconnected) cb(tunnelId, (payload as TunnelEventPayloadMap["reconnected"]).urls); + break; + case "reconnection_failed": + for (const cb of this.callbacks.reconnection_failed) cb(tunnelId, (payload as TunnelEventPayloadMap["reconnection_failed"]).retryCnt); + break; + case "error": { + const p = payload as TunnelEventPayloadMap["error"]; + for (const cb of this.callbacks.error) cb(tunnelId, p.message, p.isFatal); + break; + } + case "url_ready": + for (const cb of this.callbacks.url_ready) cb(tunnelId, (payload as TunnelEventPayloadMap["url_ready"]).urls); + break; + case "worker_error": + for (const cb of this.callbacks.worker_error) cb(tunnelId, (payload as TunnelEventPayloadMap["worker_error"]).message); + break; + case "will_reconnect": { + const p = payload as TunnelEventPayloadMap["will_reconnect"]; + for (const cb of this.callbacks.will_reconnect) cb(tunnelId, p.error, p.messages); + break; + } + case "stopped": + for (const cb of this.callbacks.stopped) cb(tunnelId); + break; + } + } +} diff --git a/src/index.ts b/src/index.ts index e11a70e..2d6945e 100644 --- a/src/index.ts +++ b/src/index.ts @@ -9,6 +9,7 @@ import CLIPrinter from "./utils/printer.js"; // Public API re-exports export { TunnelManager } from "./tunnel_manager/TunnelManager.js"; export { TunnelOperations, TunnelResponse } from "./remote_management/handler.js"; +export { TunnelClient } from "./daemon/tunnelClient.js"; export { enablePackageLogging } from "./logger.js"; export { getRemoteManagementState, diff --git a/src/logger.ts b/src/logger.ts index a9ab993..ccc5938 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -7,6 +7,28 @@ import { pinggy, LogLevel } from "@pinggy/pinggy"; +type LogLevelName = "debug" | "info" | "error"; +let _currentLevel: LogLevelName = (process.env.PINGGY_LOG_LEVEL as LogLevelName) || "info"; +let _sdkLogFilePath: string | null = null; + +export function getLogLevel(): LogLevelName { + return _currentLevel; +} + +export function setLogLevel(level: LogLevelName): void { + _currentLevel = level; + getLogger().level = level; + if (_sdkLogFilePath) { + enableLoggingByLogLevelInSdk(level, _sdkLogFilePath); + } + import("./daemon/lifecycle/daemonConfig.js") + .then(({ writeDaemonConfig }) => writeDaemonConfig({ logLevel: level })) + .catch(() => { /* CLI process or persistence failure; nothing to do. */ }); + import("./tunnel_manager/TunnelManager.js") + .then(({ TunnelManager }) => TunnelManager.getInstance().applyLogLevelToActiveTunnels(level)) + .catch(() => { /* TunnelManager not initialised yet (CLI path); nothing to propagate. */ }); +} + // Singleton logger instance let _logger: winston.Logger | null = null; function getLogger(): winston.Logger { @@ -38,6 +60,7 @@ function applyLoggingConfig(cfg: BaseLogConfig): winston.Logger { // Set SDK log level if (enableSdkLog) { enableLoggingByLogLevelInSdk(level ?? "info", filePath!); + _sdkLogFilePath = filePath ?? null; } @@ -56,10 +79,10 @@ function applyLoggingConfig(cfg: BaseLogConfig): winston.Logger { format: winston.format.combine( winston.format.colorize(), winston.format.timestamp(), - winston.format.printf(({ level, message, timestamp, ...meta }) => { - const srcLabel = source ? "[CLI] " : ""; - return `${timestamp} ${srcLabel}[${level}] ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : "" - }`; + winston.format.printf(({ level, message, timestamp, tunnelId, source: src, ...meta }) => { + const srcLabel = source && src && src !== "libpinggy" && src !== "sdk-js" ? `[${src}] ` : ""; + const tunnelLabel = tunnelId ? `[tunnel:${String(tunnelId).slice(0, 8)}] ` : ""; + return `${timestamp} ${tunnelLabel}[${level}] ${srcLabel}${message}${Object.keys(meta).length ? " " + JSON.stringify(meta) : ""}`; }) ), }) @@ -68,14 +91,20 @@ function applyLoggingConfig(cfg: BaseLogConfig): winston.Logger { // File logging if (filePath) { + const daemonFilter = winston.format((info) => { + if (info.source === "libpinggy" || info.source === "sdk-js") return false; + return info; + }); transports.push( new winston.transports.File({ filename: filePath, format: winston.format.combine( + daemonFilter(), winston.format.timestamp(), - winston.format.printf(({ level, message, timestamp, ...meta }) => { - return `${timestamp} [${level}] ${message} ${Object.keys(meta).length ? JSON.stringify(meta) : "" - }`; + winston.format.printf(({ level, message, timestamp, tunnelId, source: src, ...meta }) => { + const srcLabel = src ? `[${src}] ` : ""; + const tunnelLabel = tunnelId ? `[tunnel:${String(tunnelId).slice(0, 8)}] ` : ""; + return `${timestamp} ${tunnelLabel}[${level}] ${srcLabel}${message}${Object.keys(meta).length ? " " + JSON.stringify(meta) : ""}`; }) ), }) @@ -92,6 +121,7 @@ function applyLoggingConfig(cfg: BaseLogConfig): winston.Logger { } log.level = (level || process.env.PINGGY_LOG_LEVEL || "info").toLowerCase(); + _currentLevel = log.level as LogLevelName; log.silent = silent || transports.length === 0; return log; @@ -129,9 +159,9 @@ function enableLoggingByLogLevelInSdk(loglevel: string | undefined, logFilePath: } const l = loglevel.toUpperCase(); - if (loglevel === "DEBUG") { + if (l === "DEBUG") { pinggy.setDebugLogging(true, LogLevel.DEBUG, logFilePath); - } else if (loglevel === "ERROR") { + } else if (l === "ERROR") { pinggy.setDebugLogging(true, LogLevel.ERROR, logFilePath); } else { pinggy.setDebugLogging(true, LogLevel.INFO, logFilePath); diff --git a/src/logger/rotateLog.ts b/src/logger/rotateLog.ts new file mode 100644 index 0000000..57a6889 --- /dev/null +++ b/src/logger/rotateLog.ts @@ -0,0 +1,44 @@ +import fs from "node:fs"; + +export const DEFAULT_MAX_BYTES = 10 * 1024 * 1024; +export const DEFAULT_MAX_ARCHIVES = 3; +const ROTATE_TRIGGER_RATIO = 0.8; + +/** + * Rotate a log file in place at the only safe moment: when no writer holds a + * file descriptor on it (called from TunnelManager just before a tunnel starts). + * + * Trigger: file exists and `size >= ROTATE_TRIGGER_RATIO * maxBytes`. Rotating + * at 80% (not 100%) + * Shift: drop the oldest archive past `maxArchives`, then move every other + * archive one slot older, then rename the live file to `.1`. + * + * Old files are not deleted, they are archived. + */ +export function maybeRotate(file: string, maxBytes: number = DEFAULT_MAX_BYTES, maxArchives: number = DEFAULT_MAX_ARCHIVES): void { + let size: number; + try { + size = fs.statSync(file).size; + } catch { + // File missing or unreadable. Nothing to do. + return; + } + if (size < maxBytes * ROTATE_TRIGGER_RATIO) return; + + const archive = (n: number) => `${file}.${n}`; + + try { + if (fs.existsSync(archive(maxArchives))) { + fs.unlinkSync(archive(maxArchives)); + } + for (let n = maxArchives - 1; n >= 1; n--) { + if (fs.existsSync(archive(n))) { + fs.renameSync(archive(n), archive(n + 1)); + } + } + fs.renameSync(file, archive(1)); + } catch { + // Best-effort. A race or permission failure should not block tunnel + // start; writers will reopen the existing file in append mode. + } +} diff --git a/src/logger/tunnelLogger.ts b/src/logger/tunnelLogger.ts new file mode 100644 index 0000000..99eb2b8 --- /dev/null +++ b/src/logger/tunnelLogger.ts @@ -0,0 +1,76 @@ +import winston from "winston"; +import fs from "fs"; +import path from "path"; +import { logger } from "../logger.js"; +import { getTunnelLogPath } from "../utils/configDir.js"; + +const tunnelTransports = new Map(); + +let tunnelLoggingEnabled = true; + +export function setTunnelLoggingEnabled(enabled: boolean): void { + if (tunnelLoggingEnabled === enabled) return; + tunnelLoggingEnabled = enabled; + if (!enabled) { + detachAllTunnelLoggers(); + } +} + +export function isTunnelLoggingEnabled(): boolean { + return tunnelLoggingEnabled; +} + +/** + * Attach a per-tunnel File transport to the shared winston logger. + * Call as the first action when a tunnel is created. + * Returns a child logger pre-tagged with { tunnelId, source: "daemon" }. + * When tunnel logging is disabled, skips the file transport and returns + * a child logger only (callers still work, but nothing hits disk). + */ +export function attachTunnelLogger(tunnelId: string, origin: string, name?: string): winston.Logger { + if (!tunnelLoggingEnabled) { + return logger.child({ tunnelId, source: "daemon" }); + } + const logPath = getTunnelLogPath(tunnelId, origin, name); + fs.mkdirSync(path.dirname(logPath), { recursive: true }); + + const tunnelFilter = winston.format((info) => { + return info.tunnelId === tunnelId ? info : false; + }); + + const transport = new winston.transports.File({ + filename: logPath, + format: winston.format.combine( + tunnelFilter(), + winston.format.timestamp(), + winston.format.printf(({ level, message, timestamp, source, ...meta }) => { + const srcLabel = source ? `[${source}] ` : ""; + const { tunnelId: _tid, ...rest } = meta; + return `${timestamp} [${level}] ${srcLabel}${message}${Object.keys(rest).length ? " " + JSON.stringify(rest) : ""}`; + }) + ), + }); + + logger.add(transport); + tunnelTransports.set(tunnelId, transport); + + return logger.child({ tunnelId, source: "daemon" }); +} + +/** + * Detach and close the per-tunnel File transport. + * Call from stopTunnel and creation-failure cleanup paths. + */ +export function detachTunnelLogger(tunnelId: string): void { + const transport = tunnelTransports.get(tunnelId); + if (!transport) return; + logger.remove(transport); + tunnelTransports.delete(tunnelId); + (transport as winston.transport & { close?: () => void }).close?.(); +} + +export function detachAllTunnelLoggers(): void { + for (const tunnelId of tunnelTransports.keys()) { + detachTunnelLogger(tunnelId); + } +} diff --git a/src/main.ts b/src/main.ts index 17ec152..313e01f 100644 --- a/src/main.ts +++ b/src/main.ts @@ -2,7 +2,7 @@ import { TunnelManager } from "./tunnel_manager/TunnelManager.js"; import { printHelpMessage } from "./cli/help.js"; import { cliOptions } from "./cli/options.js"; -import { configureLogger, logger } from "./logger.js"; +import { configureLogger, enablePackageLogging, logger } from "./logger.js"; import { parseCliArgs } from "./utils/parseArgs.js"; import CLIPrinter from "./utils/printer.js"; import { getVersion } from "./utils/util.js"; @@ -10,41 +10,39 @@ import { TunnelOperations, TunnelResponse } from "./remote_management/handler.js import { fileURLToPath } from 'url'; import { argv } from 'process'; import { realpathSync } from 'fs'; -import { enablePackageLogging } from "./logger.js" import { getRemoteManagementState, initiateRemoteManagement, closeRemoteManagement, RemoteManagementUnauthorizedError } from "./remote_management/remoteManagement.js"; import { buildAndStartTunnel } from "./cli/buildAndStartTunnel.js"; -import { isSubcommand, handleSubcommand } from "./cli/subcommands.js"; +import { isSubcommand, handleSubcommand } from "./cli/subcommand/subcommands.js"; +import { runDaemonChild, DaemonHandle, RunDaemonOptions, DaemonInfo } from "./daemon/lifecycle/daemonChild.js"; +import { ensureDaemonRunning, getActiveTunnelSummaries, getDaemonInfo, getInProcessDaemonHandle, isDaemonRunning, ActiveTunnelSummary } from "./daemon/lifecycle/daemonManager.js"; export { TunnelManager, TunnelOperations, TunnelResponse, enablePackageLogging, getRemoteManagementState, initiateRemoteManagement, closeRemoteManagement, RemoteManagementUnauthorizedError }; +export { runDaemonChild, ensureDaemonRunning, getActiveTunnelSummaries, getDaemonInfo, getInProcessDaemonHandle, isDaemonRunning }; +export type { DaemonHandle, RunDaemonOptions, DaemonInfo, ActiveTunnelSummary }; async function main() { try { - const rawArgs = process.argv.slice(2); - const manager = TunnelManager.getInstance(); - // Keep the process alive and handle graceful shutdown - const gracefulShutdown = (signal: string) => { - logger.info(`${signal} received: stopping tunnels and exiting`); - console.log("\nStopping all tunnels..."); - manager.stopAllTunnels(); - console.log("Tunnels stopped. Exiting."); - process.exit(0); - }; + const rawArgs = process.argv.slice(2); + // Parse arguments from the command line + const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions); - process.on('SIGINT', () => gracefulShutdown('SIGINT')); - process.on('SIGTERM', () => gracefulShutdown('SIGTERM')); + + configureLogger(values); + + // Early branch: if this is the daemon child process, run daemon mode and return + if (values["_daemon-child"]) { + const { runDaemonChild } = await import("./daemon/lifecycle/daemonChild.js"); + await runDaemonChild(); + return; + } // Subcommand mode: `pinggy config ...` or `pinggy start ...` if (isSubcommand(rawArgs)) { - await handleSubcommand(rawArgs, manager); + await handleSubcommand(rawArgs); return; } - // Tunnel creation mode: parse all flags - const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions); - - configureLogger(values); - if (!hasAnyArgs || values.help) { printHelpMessage(); return; @@ -55,7 +53,7 @@ async function main() { } // Default: build config from CLI args, optionally save, and start tunnel - await buildAndStartTunnel(values, positionals, manager); + await buildAndStartTunnel(values, positionals); } catch (error) { logger.error("Unhandled error in CLI:", error); @@ -78,5 +76,5 @@ try { // If this file executed directly from Node then only run main() // otherwise (if imported as module), do nothing. if (entryFile && entryFile === currentFile) { - main(); + void main(); } diff --git a/src/remote_management/handler.ts b/src/remote_management/handler.ts index f67273f..5058b32 100644 --- a/src/remote_management/handler.ts +++ b/src/remote_management/handler.ts @@ -10,9 +10,10 @@ import { ErrorCodeType } from "../types.js"; import { logger } from "../logger.js"; -import { DisconnectListener, TunnelManager } from "../tunnel_manager/TunnelManager.js"; +import { DisconnectListener, TunnelAlreadyRunningError, TunnelManager, TunnelOrigin } from "../tunnel_manager/TunnelManager.js"; import { pinggyOptionsToTunnelConfig, tunnelConfigToPinggyOptions, TunnelConfig, TunnelConfigV1, pinggyOptionsToTunnelConfigV1 } from "./remote_schema.js"; import { TunnelConfigurationV1, TunnelUsageType } from "@pinggy/pinggy"; +import { SessionMode } from "../daemon/ipc/ipcRoutes.js"; export interface TunnelResponse { tunnelid: string; @@ -29,9 +30,10 @@ export interface TunnelResponseV2 { status: Status; stats: TunnelUsageType; greetmsg?: string; + mode?: SessionMode; } -interface TunnelHandler { +export interface TunnelHandler { handleStart(config: TunnelConfig, noWait?: boolean): Promise; handleStartV2(config: TunnelConfigV1, noWait?: boolean): Promise; handleUpdateConfig(config: TunnelConfig, noWait?: boolean): Promise; @@ -43,7 +45,7 @@ interface TunnelHandler { handleRestart(tunnelid: string, noWait?: boolean): Promise; handleRegisterStatsListener(tunnelid: string, listener: (tunnelId: string, stats: TunnelUsageType) => void): void; handleUnregisterStatsListener(tunnelid: string, listnerId: string): void; - handleGetTunnelStats(tunnelid: string): TunnelUsageType[] | ErrorResponse; + handleGetTunnelStats(tunnelid: string): Promise; handleRegisterDisconnectListener(tunnelid: string, listener: DisconnectListener): void; handleRemoveStoppedTunnelByTunnelId(tunnelId: string): boolean | ErrorResponse; handleRemoveStoppedTunnelByConfigId(configId: string): boolean | ErrorResponse; @@ -99,9 +101,9 @@ export class TunnelOperations implements TunnelHandler { // --- Helper to construct TunnelResponse --- private async buildTunnelResponse(tunnelid: string, tunnelConfig: TunnelConfigurationV1, configid: string, tunnelName: string, serve?: string): Promise { - const [status, stats, tlsInfo, greetMsg, remoteurls] = await Promise.all([ + const stats = this.tunnelManager.getLatestTunnelStats(tunnelid) || newStats(); + const [status, tlsInfo, greetMsg, remoteurls] = await Promise.all([ this.tunnelManager.getTunnelStatus(tunnelid), - this.tunnelManager.getLatestTunnelStats(tunnelid) || newStats(), this.tunnelManager.getLocalserverTlsInfo(tunnelid), this.tunnelManager.getTunnelGreetMessage(tunnelid), this.tunnelManager.getTunnelUrls(tunnelid) @@ -118,9 +120,9 @@ export class TunnelOperations implements TunnelHandler { private async buildTunnelResponseV2(tunnelid: string, tunnelConfig: TunnelConfigurationV1, configFromCli: TunnelConfigurationV1, configid: string, tunnelName: string, serve?: string): Promise { - const [status, stats, greetMsg, remoteurls] = await Promise.all([ + const stats = this.tunnelManager.getLatestTunnelStats(tunnelid) || newStats(); + const [status, greetMsg, remoteurls] = await Promise.all([ this.tunnelManager.getTunnelStatus(tunnelid), - this.tunnelManager.getLatestTunnelStats(tunnelid) || newStats(), this.tunnelManager.getTunnelGreetMessage(tunnelid), this.tunnelManager.getTunnelUrls(tunnelid) ]); @@ -143,7 +145,7 @@ export class TunnelOperations implements TunnelHandler { } // --- Operations --- - async handleStart(config: TunnelConfig, noWait = false): Promise { + async handleStart(config: TunnelConfig, noWait = false, origin: TunnelOrigin = "cli"): Promise { try { // Convert TunnelConfig -> PinggyOptions const opts = tunnelConfigToPinggyOptions(config); @@ -154,7 +156,7 @@ export class TunnelOperations implements TunnelHandler { optional:{ serve: config.serve, } - }); + }, origin); const { tunnelid, tunnelName, serve, tunnelConfig } = managed; const startPromise = this.tunnelManager.startTunnel(tunnelid); @@ -174,18 +176,27 @@ export class TunnelOperations implements TunnelHandler { } } - async handleStartV2(config: TunnelConfigV1, noWait = false): Promise { + async handleStartV2(config: TunnelConfigV1, noWait = false, origin: TunnelOrigin = "cli"): Promise { try { - // Convert TunnelConfigV1 -> PinggyOptions - const managed = await this.tunnelManager.createTunnel(config); - const { tunnelid, serve, tunnelConfig } = managed; + const managed = await this.tunnelManager.createTunnel(config, origin); + const { tunnelid, tunnelConfig } = managed; - await this.tunnelManager.startTunnel(tunnelid); - + const startPromise = this.tunnelManager.startTunnel(tunnelid); + if (noWait) { + startPromise.catch(err => { + logger.error("No-wait startTunnel failed", { tunnelid, err: String(err) }); + }); + return this.buildPendingTunnelResponseV2(tunnelid, tunnelConfig!, config, config.configId, config.name as string, config.serve); + } + + await startPromise; const tunnelPconfig = await this.tunnelManager.getTunnelConfig("", tunnelid); return this.buildTunnelResponseV2(tunnelid, tunnelPconfig, config, config.configId, config.name, config.serve); } catch (err) { + if (err instanceof TunnelAlreadyRunningError) { + return this.error(ErrorCode.TunnelAlreadyRunningError, err, err.message); + } return this.error(ErrorCode.ErrorStartingTunnel, err, "Unknown error occurred while starting tunnel"); } } @@ -226,7 +237,6 @@ export class TunnelOperations implements TunnelHandler { try { if (noWait) { const existing = this.tunnelManager.getManagedTunnel(config.configId); - console.log(existing); if (!existing.tunnelConfig) throw new Error("Invalid tunnel state before configuration update"); this.tunnelManager.updateConfig(config).catch(err => { logger.error("No-wait updateConfigV2 failed", { configId: config.configId, err: String(err) }); @@ -357,28 +367,27 @@ export class TunnelOperations implements TunnelHandler { } } handleRegisterStatsListener(tunnelid: string, listener: (tunnelId: string, stats: TunnelUsageType) => void): void { - this.tunnelManager.registerStatsListener(tunnelid, listener); + void this.tunnelManager.registerStatsListener(tunnelid, listener); } handleUnregisterStatsListener(tunnelid: string, listnerId: string): void { this.tunnelManager.deregisterStatsListener(tunnelid, listnerId); } - handleGetTunnelStats(tunnelid: string): TunnelUsageType[] | ErrorResponse { + handleGetTunnelStats(tunnelid: string): Promise { try { const stats = this.tunnelManager.getTunnelStats(tunnelid); if (!stats) { - // if no stats found, return new stats object - return [newStats()]; + return Promise.resolve([newStats()]); } - return stats; + return Promise.resolve(stats); } catch (err) { - return this.error(ErrorCode.TunnelNotFound, err, "Failed to get tunnel stats"); + return Promise.resolve(this.error(ErrorCode.TunnelNotFound, err, "Failed to get tunnel stats")); } } handleRegisterDisconnectListener(tunnelid: string, listener: DisconnectListener): void { - this.tunnelManager.registerDisconnectListener(tunnelid, listener); + void this.tunnelManager.registerDisconnectListener(tunnelid, listener); } handleRemoveStoppedTunnelByConfigId(configId: string): boolean | ErrorResponse { diff --git a/src/remote_management/remoteManagement.ts b/src/remote_management/remoteManagement.ts index 2054cf1..d385c0d 100644 --- a/src/remote_management/remoteManagement.ts +++ b/src/remote_management/remoteManagement.ts @@ -4,6 +4,7 @@ import { handleConnectionStatusMessage, sendVersionResponse, WebSocketCommandHan import CLIPrinter from "../utils/printer.js"; import { RemoteManagementState, RemoteManagementStatus } from "../types.js"; import { RemoteManagementConfig } from "@pinggy/pinggy" +import { TunnelHandler } from "./handler.js"; const RECONNECT_SLEEP_MS = 5000; // 5 seconds const PING_INTERVAL_MS = 30000; // 30 seconds @@ -55,7 +56,7 @@ function sleep(ms: number) { return new Promise((res) => setTimeout(res, ms)); } -export async function parseRemoteManagement(values: RemoteManagementValues): Promise { +export async function parseRemoteManagement(values: RemoteManagementValues, tunnelHandler?: TunnelHandler): Promise { const rmToken = values["remote-management"]; if (typeof rmToken === "string" && rmToken.trim().length > 0) { const manageHost = values["manage"]; @@ -64,7 +65,7 @@ export async function parseRemoteManagement(values: RemoteManagementValues): Pro apiKey: rmToken, serverUrl: buildRemoteManagementWsUrl(manageHost), }; - await initiateRemoteManagement(remoteManagementConfig); + await initiateRemoteManagement(remoteManagementConfig, tunnelHandler); return { ok: true }; } catch (e) { logger.error("Failed to initiate remote management:", e); @@ -80,7 +81,7 @@ export async function parseRemoteManagement(values: RemoteManagementValues): Pro * - On other failures: retry every 15 seconds * - Keep running until closed or SIGINT */ -export async function initiateRemoteManagement( remoteManagementConfig: RemoteManagementConfig): Promise { +export async function initiateRemoteManagement( remoteManagementConfig: RemoteManagementConfig, tunnelHandler?: TunnelHandler): Promise { if (!remoteManagementConfig.apiKey || remoteManagementConfig.apiKey.trim().length === 0) { throw new Error("Remote management token is required (use --remote-management )"); @@ -106,7 +107,7 @@ export async function initiateRemoteManagement( remoteManagementConfig: RemoteMa logConnecting(); setRemoteManagementState({ status: RemoteManagementStatus.Connecting, errorMessage: "" }); try { - await handleWebSocketConnection(wsUrl, wsHost, remoteManagementConfig.apiKey); + await handleWebSocketConnection(wsUrl, wsHost, remoteManagementConfig.apiKey, undefined, tunnelHandler); } catch (error) { if (error instanceof RemoteManagementUnauthorizedError) { throw error; @@ -127,7 +128,7 @@ export async function initiateRemoteManagement( remoteManagementConfig: RemoteMa return getRemoteManagementState(); } -async function handleWebSocketConnection(wsUrl: string, wsHost: string, token: string, onOpenCallback?: () => void): Promise { +async function handleWebSocketConnection(wsUrl: string, wsHost: string, token: string, onOpenCallback?: () => void, tunnelHandler?: TunnelHandler): Promise { return new Promise((resolve, reject) => { const ws = new WebSocket(wsUrl, { @@ -180,7 +181,7 @@ async function handleWebSocketConnection(wsUrl: string, wsHost: string, token: s } setRemoteManagementState({ status: RemoteManagementStatus.Running, errorMessage: "" }); const req = JSON.parse(data.toString("utf8")) as WebSocketRequest; - await new WebSocketCommandHandler().handle(ws, req); + await new WebSocketCommandHandler(tunnelHandler).handle(ws, req); } catch (e) { logger.warn("Failed handling websocket message", { error: String(e) }); } @@ -248,7 +249,7 @@ export async function closeRemoteManagement(timeoutMs = 10000): Promise { +export function startRemoteManagement(remoteManagementConfig: RemoteManagementConfig, tunnelHandler?: TunnelHandler): Promise { if (!remoteManagementConfig.apiKey || remoteManagementConfig.apiKey.trim().length === 0) { return Promise.reject(new Error("Remote management token is required")); } @@ -283,7 +284,7 @@ export function startRemoteManagement(remoteManagementConfig: RemoteManagementCo setRemoteManagementState({ status: RemoteManagementStatus.Connecting, errorMessage: "" }); try { - await handleWebSocketConnection(wsUrl, wsHost, remoteManagementConfig.apiKey, () => settleOnce()); + await handleWebSocketConnection(wsUrl, wsHost, remoteManagementConfig.apiKey, () => settleOnce(), tunnelHandler); } catch (error) { if (error instanceof RemoteManagementUnauthorizedError) { settleOnce(error); diff --git a/src/remote_management/remote_schema.ts b/src/remote_management/remote_schema.ts index 961c4e1..3c34b54 100644 --- a/src/remote_management/remote_schema.ts +++ b/src/remote_management/remote_schema.ts @@ -108,7 +108,7 @@ export type TunnelConfig = z.infer; export const ForwardingEntryV2Schema = z.object({ listenAddress: z.string().optional(), address: z.string(), - type: z.enum([TunnelType.Http, TunnelType.Tcp, TunnelType.Udp, TunnelType.Tls, TunnelType.TlsTcp]).optional(), + type: z.enum([TunnelType.Http, TunnelType.Tcp, TunnelType.Udp, TunnelType.Tls, TunnelType.TlsTcp]), }); /** diff --git a/src/remote_management/websocket_handlers.ts b/src/remote_management/websocket_handlers.ts index dd0863e..2c15a62 100644 --- a/src/remote_management/websocket_handlers.ts +++ b/src/remote_management/websocket_handlers.ts @@ -1,12 +1,12 @@ import WebSocket from "ws"; import { logger } from "../logger.js"; -import { ErrorCode, NewErrorResponseObject, ResponseObj, ErrorResponse, isErrorResponse, NewResponseObject } from "../types.js"; -import { TunnelOperations, TunnelResponse, TunnelResponseV2 } from "./handler.js"; +import { ErrorCode, ErrorCodeType, NewErrorResponseObject, ResponseObj, ErrorResponse, isErrorResponse, NewResponseObject } from "../types.js"; +import { TunnelHandler, TunnelOperations, TunnelResponse, TunnelResponseV2 } from "./handler.js"; import { GetSchema, RestartSchema, StartSchema, StartV2Schema, StopSchema, UpdateConfigSchema, UpdateConfigV2Schema } from "./remote_schema.js"; import { remoteManagementWebSocketPrinter } from "./websocket_printer.js"; import z from "zod"; import CLIPrinter from "../utils/printer.js"; -import { getVersion } from "../utils/util.js"; +import { errorMessage, getVersion } from "../utils/util.js"; export interface ConnectionStatus { success: boolean; @@ -20,11 +20,24 @@ export interface WebSocketRequest { data?: string; } -type CommandName = "start" | "start-v2" | "stop" | "get" | "restart" | "updateconfig" | "update-config-v2" | "list" | "get-version" | "list-v2"; +export const WsCommand = { + Start: "start", + StartV2: "start-v2", + Stop: "stop", + Get: "get", + Restart: "restart", + UpdateConfig: "updateconfig", + UpdateConfigV2: "update-config-v2", + List: "list", + ListV2: "list-v2", + GetVersion: "get-version", +} as const; +export type WsCommand = typeof WsCommand[keyof typeof WsCommand]; export class WebSocketCommandHandler { - private tunnelHandler = new TunnelOperations(); - constructor() { + private tunnelHandler: TunnelHandler; + constructor(handler?: TunnelHandler) { + this.tunnelHandler = handler ?? new TunnelOperations(); remoteManagementWebSocketPrinter.setTunnelHandler(this.tunnelHandler); } @@ -46,7 +59,7 @@ export class WebSocketCommandHandler { ws.send(JSON.stringify(payload)); } - private sendError(ws: WebSocket, req: Partial, message: string, code = ErrorCode.InternalServerError) { + private sendError(ws: WebSocket, req: Partial, message: string, code: ErrorCodeType = ErrorCode.InternalServerError) { const resp = NewErrorResponseObject({ code, message }); resp.command = req.command || ""; resp.requestid = req.requestid || ""; @@ -198,7 +211,7 @@ export class WebSocketCommandHandler { } } - private async handleGetVersionReq(ws: WebSocket, req: WebSocketRequest): Promise { + private handleGetVersionReq(ws: WebSocket, req: WebSocketRequest): void { try { const versionResponse = { cli_version: getVersion(), @@ -242,49 +255,49 @@ export class WebSocketCommandHandler { } async handle(ws: WebSocket, req: WebSocketRequest) { - const cmd = (req.command || "").toLowerCase() as CommandName | string; + const cmd = (req.command || "").toLowerCase() as WsCommand | string; const raw = this.safeParse(req.data); try { let response: ResponseObj; - switch (cmd as CommandName) { - case "start": { + switch (cmd as WsCommand) { + case WsCommand.Start: { response = await this.handleStartReq(req, raw); break; } - case "start-v2": { + case WsCommand.StartV2: { response = await this.handleStartV2Req(req, raw); break; } - case "stop": { + case WsCommand.Stop: { response = await this.handleStopReq(req, raw); break; } - case "get": { + case WsCommand.Get: { response = await this.handleGetReq(req, raw); break; } - case "restart": { + case WsCommand.Restart: { response = await this.handleRestartReq(req, raw); break; } - case "updateconfig": { + case WsCommand.UpdateConfig: { response = await this.handleUpdateConfigReq(req, raw); break; } - case "update-config-v2": { + case WsCommand.UpdateConfigV2: { response = await this.handleUpdateConfigV2Req(req, raw); break; } - case "list": { + case WsCommand.List: { response = await this.handleListReq(req); break; } - case "list-v2": { + case WsCommand.ListV2: { response = await this.handleListV2Req(req); break; } - case "get-version": { - await this.handleGetVersionReq(ws, req); + case WsCommand.GetVersion: { + this.handleGetVersionReq(ws, req); return; } default: @@ -295,13 +308,13 @@ export class WebSocketCommandHandler { } logger.debug("Sending response", { command: response.command, requestid: response.requestid }); this.sendResponse(ws, response); - } catch (e: any) { + } catch (e) { if (e instanceof z.ZodError) { logger.warn("Validation failed", { cmd, issues: e.issues }); return this.sendError(ws, req, "Invalid request data", ErrorCode.InvalidBodyFormatError); } - logger.error("Error handling command", { cmd, error: String(e) }); - return this.sendError(ws, req, e?.message || "Internal error"); + logger.error("Error handling command", { cmd, error: errorMessage(e) }); + return this.sendError(ws, req, errorMessage(e) || "Internal error"); } } } @@ -312,7 +325,7 @@ export function sendVersionResponse(ws: WebSocket) { }; const payload = { - command: "get-version", + command: WsCommand.GetVersion, requestid: "0", response: JSON.stringify(versionResponse), error: false, diff --git a/src/remote_management/websocket_printer.ts b/src/remote_management/websocket_printer.ts index 525206d..a18ef91 100644 --- a/src/remote_management/websocket_printer.ts +++ b/src/remote_management/websocket_printer.ts @@ -1,11 +1,9 @@ import { logger } from "../logger.js"; -import { TunnelManager } from "../tunnel_manager/TunnelManager.js"; import { ErrorResponse, isErrorResponse, TunnelStateType } from "../types.js"; import CLIPrinter from "../utils/printer.js"; -import { TunnelResponse, TunnelResponseV2 } from "./handler.js"; +import { TunnelHandler, TunnelResponse, TunnelResponseV2 } from "./handler.js"; import { TunnelConfig, TunnelConfigV1 } from "./remote_schema.js"; import pico from "picocolors"; -import type { TunnelOperations } from "./handler.js"; type StartRequestConfig = TunnelConfig | TunnelConfigV1; type StartResponse = TunnelResponse | TunnelResponseV2 | ErrorResponse; @@ -22,12 +20,11 @@ interface PendingStartEntry { const PENDING_START_TIMEOUT_MS = 5 * 60 * 1000; class RemoteManagementWebSocketPrinter { - private readonly tunnelManager = TunnelManager.getInstance(); private readonly pendingStarts = new Map(); - private tunnelHandler?: TunnelOperations; + private tunnelHandler?: TunnelHandler; private latestPendingConfigId?: string; - setTunnelHandler(tunnelHandler: TunnelOperations) { + setTunnelHandler(tunnelHandler: TunnelHandler) { this.tunnelHandler = tunnelHandler; } @@ -214,25 +211,17 @@ class RemoteManagementWebSocketPrinter { } private resolveTunnelDetails(tunnelId: string, result?: TunnelResponse | ErrorResponse) { - try { - const managed = this.tunnelManager.getManagedTunnel(undefined, tunnelId); + if (result && !isErrorResponse(result)) { return { - configId: managed.configId, - configName: managed.tunnelName || managed.configId || tunnelId, - }; - } catch { - if (result && !isErrorResponse(result)) { - return { - configId: this.getConfigIdFromTunnel(result), - configName: this.getConfigNameFromTunnel(result), - }; - } - - return { - configId: tunnelId, - configName: tunnelId, + configId: this.getConfigIdFromTunnel(result), + configName: this.getConfigNameFromTunnel(result), }; } + + return { + configId: tunnelId, + configName: tunnelId, + }; } private getConfigIdFromRequest(config: StartRequestConfig): string { diff --git a/src/tui/blessed/TunnelTui.ts b/src/tui/blessed/TunnelTui.ts index 6c977f3..4e3690e 100644 --- a/src/tui/blessed/TunnelTui.ts +++ b/src/tui/blessed/TunnelTui.ts @@ -31,6 +31,14 @@ import { KeyBindingsCallbacks, } from "./components/KeyBindings.js"; +export interface TuiStopHandler { + (): Promise | void; +} + +declare global { + var __PINGGY_TUNNEL_STATS__: ((stats: TunnelUsageType) => void) | undefined; +} + interface TunnelAppProps { urls: string[]; greet?: string; @@ -40,7 +48,9 @@ interface TunnelAppProps { error?: string; messages?: string[]; } | null; - tunnelInstance?:ManagedTunnel + tunnelInstance?:ManagedTunnel; + /** Optional custom stop handler (used when tunnel runs in daemon) */ + onStop?: TuiStopHandler; } export class TunnelTui { @@ -82,6 +92,7 @@ export class TunnelTui { fetchAbortController: null, }; private tunnelInstance?: ManagedTunnel + private onStop?: TuiStopHandler; private exitPromiseResolve: (() => void) | null = null; private exitPromise: Promise; @@ -91,6 +102,7 @@ export class TunnelTui { this.greet = props.greet || ""; this.tunnelConfig = props.tunnelConfig; this.disconnectInfo = props.disconnectInfo; + this.onStop = props.onStop; if(props.tunnelInstance){ this.tunnelInstance=props.tunnelInstance } @@ -107,7 +119,7 @@ export class TunnelTui { this.setupStatsListener(); this.setupWebDebugger(); - this.generateQrCodes(); + void this.generateQrCodes(); this.createUI(); this.setupKeyBindings(); } @@ -174,10 +186,6 @@ export class TunnelTui { if (width < MIN_WIDTH_WARNING) { this.uiElements = { mainContainer: createWarningUI(this.screen), - urlsBox: null as any, - statsBox: null as any, - requestsBox: null as any, - footerBox: null as any, warningBox: createWarningUI(this.screen), }; this.screen.render(); @@ -342,14 +350,58 @@ export class TunnelTui { return this.exitPromise; } - public destroy() { - // Stop the tunnel first - if (this.tunnelInstance?.tunnelid) { + /** + * Update stats externally (used when TUI receives data from daemon WS stream). + */ + public updateStats(newStats: TunnelUsageType) { + this.stats = { ...newStats }; + this.updateStatsDisplay(); + } + + /** + * Update URLs externally (used on reconnect from daemon WS stream). + */ + public updateUrls(newUrls: string[]) { + this.urls = newUrls; + this.updateUrlsDisplay(); + void this.generateQrCodes(); + } + + /** + * Show disconnect modal externally (from daemon WS stream). + */ + public showDisconnectModal(error: string, messages?: string[]) { + this.updateDisconnectInfo({ + disconnected: true, + error, + messages, + }); + } + + /** + * Stop TUI without stopping tunnel (used for detach). + */ + public stop() { + delete globalThis.__PINGGY_TUNNEL_STATS__; + if (this.webDebuggerConnection) { + this.webDebuggerConnection.close(); + } + this.screen.destroy(); + if (this.exitPromiseResolve) { + this.exitPromiseResolve(); + } + } + + public async destroy() { + // Stop the tunnel — use custom handler if provided (daemon mode), + // otherwise fall back to direct TunnelManager call. + if (this.onStop) { + await this.onStop(); + } else if (this.tunnelInstance?.tunnelid) { const manager = TunnelManager.getInstance(); manager.stopTunnel(this.tunnelInstance.tunnelid); } - // Cleanup delete globalThis.__PINGGY_TUNNEL_STATS__; if (this.webDebuggerConnection) { diff --git a/src/tui/blessed/components/DisplayUpdaters.ts b/src/tui/blessed/components/DisplayUpdaters.ts index 2dd84b7..e3bf08a 100644 --- a/src/tui/blessed/components/DisplayUpdaters.ts +++ b/src/tui/blessed/components/DisplayUpdaters.ts @@ -4,6 +4,8 @@ import { ReqResPair } from "../../../types.js"; import { getBytesInt, getStatusColor } from "../../ink/utils/utils.js"; import { getTuiConfig } from "../config.js"; +type BoxWithParseContent = blessed.Widgets.BoxElement & { parseContent(): void }; + /** * Updates the URLs display box with viewport scrolling. * Viewport follows currentQrIndex; shows ↑/↓ indicators when URLs overflow. @@ -81,7 +83,7 @@ Total Transfer: ${getBytesInt(stats.numTotalTxBytes)}`; statsBox.setContent(content); statsBox.style = { ...statsBox.style }; - (statsBox as any).parseContent(); + (statsBox as BoxWithParseContent).parseContent(); screen.render(); } @@ -213,6 +215,6 @@ export function updateQrCodeDisplay( content += qrCodes[currentQrIndex] || ""; qrCodeBox.setContent(content); qrCodeBox.style = { ...qrCodeBox.style }; - (qrCodeBox as any).parseContent(); + (qrCodeBox as BoxWithParseContent).parseContent(); screen.render(); } diff --git a/src/tui/blessed/components/KeyBindings.ts b/src/tui/blessed/components/KeyBindings.ts index 0e590c6..24f0760 100644 --- a/src/tui/blessed/components/KeyBindings.ts +++ b/src/tui/blessed/components/KeyBindings.ts @@ -16,7 +16,7 @@ export interface KeyBindingsState { export interface KeyBindingsCallbacks { onQrIndexChange: (index: number) => void; onSelectedIndexChange: (index: number, requestKey: number | null) => void; - onDestroy: () => void; + onDestroy: () => Promise | void; updateUrlsDisplay: () => void; updateQrCodeDisplay: () => void; updateRequestsDisplay: () => void; @@ -51,9 +51,9 @@ export function setupKeyBindings( } }; - // Exit on Ctrl+C - screen.key(["C-c"], () => { - callbacks.onDestroy(); + // Exit on Ctrl+C + screen.key(["C-c"], async () => { + await callbacks.onDestroy(); process.exit(0); }); @@ -173,9 +173,9 @@ export function setupKeyBindings( closeLoadingModal(screen, modalManager); modalManager.fetchAbortController = null; showDetailModal(screen, modalManager, headers.req, headers.res); - } catch (err: any) { + } catch (err) { // Don't show error if request was cancelled by user - if (err?.name === 'AbortError' || abortController.signal.aborted) { + if (err instanceof Error && err.name === 'AbortError' || abortController.signal.aborted) { logger.info("Fetch request cancelled by user"); return; } @@ -184,9 +184,9 @@ export function setupKeyBindings( closeLoadingModal(screen, modalManager); modalManager.fetchAbortController = null; - const errorMessage = err?.message || String(err) || "Unknown error occurred"; + const message = err instanceof Error ? err.message : String(err) || "Unknown error occurred"; logger.error("Fetch error:", err); - showErrorModal(screen, modalManager, "Failed to fetch request details", errorMessage); + showErrorModal(screen, modalManager, "Failed to fetch request details", message); } } }); diff --git a/src/tui/blessed/components/UIComponents.ts b/src/tui/blessed/components/UIComponents.ts index 85aa72f..c1878c9 100644 --- a/src/tui/blessed/components/UIComponents.ts +++ b/src/tui/blessed/components/UIComponents.ts @@ -9,11 +9,11 @@ export interface UIElements { mainContainer: blessed.Widgets.BoxElement; logoBox?: blessed.Widgets.BoxElement; contentBox?: blessed.Widgets.BoxElement; - urlsBox: blessed.Widgets.BoxElement; - statsBox: blessed.Widgets.BoxElement; + urlsBox?: blessed.Widgets.BoxElement; + statsBox?: blessed.Widgets.BoxElement; requestsBox?: blessed.Widgets.BoxElement; qrCodeBox?: blessed.Widgets.BoxElement; - footerBox: blessed.Widgets.BoxElement; + footerBox?: blessed.Widgets.BoxElement; warningBox?: blessed.Widgets.BoxElement; } diff --git a/src/tui/blessed/headerFetcher.ts b/src/tui/blessed/headerFetcher.ts index 7062c2b..df80b52 100644 --- a/src/tui/blessed/headerFetcher.ts +++ b/src/tui/blessed/headerFetcher.ts @@ -31,12 +31,12 @@ export async function fetchReqResHeaders( const [req, res] = await Promise.all([reqRes.text(), resRes.text()]); return { req, res }; - } catch (err: any) { + } catch (err) { // Re-throw abort errors so caller can handle cancellation - if (err?.name === 'AbortError') { + if (err instanceof Error && err.name === 'AbortError') { throw err; } - logger.error("Error fetching headers:", err.message || err); + logger.error("Error fetching headers:", err instanceof Error ? err.message : err); throw err; } } diff --git a/src/tui/blessed/webDebuggerConnection.ts b/src/tui/blessed/webDebuggerConnection.ts index ed645ee..a528ee0 100644 --- a/src/tui/blessed/webDebuggerConnection.ts +++ b/src/tui/blessed/webDebuggerConnection.ts @@ -1,5 +1,5 @@ import WebSocket from "ws"; -import { ReqResPair, WebDebuggerSocketRequest } from "../../types.js"; +import { ReqResPair, Request, WebDebuggerSocketRequest } from "../../types.js"; import { logger } from "../../logger.js"; import { getTuiConfig } from "./config.js"; @@ -79,7 +79,7 @@ export function createWebDebuggerConnection( const { key } = msg.Res; const existing = pairs.get(key) as ReqResPair | undefined; const merged: ReqResPair = { - request: existing?.request ?? ({} as any), + request: existing?.request ?? ({} as Request), response: msg.Res, } as ReqResPair; upsertPair(key, merged); @@ -95,8 +95,8 @@ export function createWebDebuggerConnection( } } onUpdate(reversedPairs); - } catch (err: any) { - logger.error("Error parsing WebSocket message:", err.message || err); + } catch (err) { + logger.error("Error parsing WebSocket message:", err instanceof Error ? err.message : err); } }); diff --git a/src/tui/ink/hooks/useQrCodes.ts b/src/tui/ink/hooks/useQrCodes.ts index e810d99..01a1272 100644 --- a/src/tui/ink/hooks/useQrCodes.ts +++ b/src/tui/ink/hooks/useQrCodes.ts @@ -20,7 +20,7 @@ export function useQrCodes(urls: string[], isQrCodeRequested: boolean) { setQrCodes(codes); }; - generateAll(); + void generateAll(); }, [urls, isQrCodeRequested]); return qrCodes; diff --git a/src/tui/ink/hooks/useReqResHeaders.ts b/src/tui/ink/hooks/useReqResHeaders.ts index 4710f57..6aed552 100644 --- a/src/tui/ink/hooks/useReqResHeaders.ts +++ b/src/tui/ink/hooks/useReqResHeaders.ts @@ -18,8 +18,8 @@ export function useReqResHeaders(baseUrl?: string) { const [req, res] = await Promise.all([reqRes.text(), resRes.text()]); setHeaders({ req, res }); - } catch (err: any) { - logger.error("Error fetching headers:", err.message || err); + } catch (err) { + logger.error("Error fetching headers:", err instanceof Error ? err.message : err); } } diff --git a/src/tui/ink/hooks/useWebDebugger.ts b/src/tui/ink/hooks/useWebDebugger.ts index bcc0ca5..cfd4e18 100644 --- a/src/tui/ink/hooks/useWebDebugger.ts +++ b/src/tui/ink/hooks/useWebDebugger.ts @@ -1,7 +1,7 @@ import { useEffect, useRef, useState } from "react"; import WebSocket from "ws"; -import { ReqResPair, WebDebuggerSocketRequest } from "../../../types.js"; +import { ReqResPair, Request, WebDebuggerSocketRequest } from "../../../types.js"; import { logger } from "../../../logger.js"; export function useWebDebugger(webDebuggerUrl?: string) { @@ -53,7 +53,7 @@ export function useWebDebugger(webDebuggerUrl?: string) { const existing = newMap.get(key) as ReqResPair | undefined; const merged = { - request: existing?.request ?? ({} as any), + request: existing?.request ?? ({} as Request), response: msg.Res, reqHeaders: existing?.reqHeaders ?? {}, resHeaders: existing?.resHeaders ?? {}, @@ -64,8 +64,8 @@ export function useWebDebugger(webDebuggerUrl?: string) { return newMap; }); - } catch (err: any) { - logger.error("Error parsing WebSocket message:", err.message || err); + } catch (err) { + logger.error("Error parsing WebSocket message:", err instanceof Error ? err.message : err); } }); diff --git a/src/tunnel_manager/TunnelManager.ts b/src/tunnel_manager/TunnelManager.ts index 5eb7fbe..386d23c 100644 --- a/src/tunnel_manager/TunnelManager.ts +++ b/src/tunnel_manager/TunnelManager.ts @@ -13,28 +13,44 @@ * @sealed * @singleton */ -import { pinggy, type TunnelConfigurationV1, type TunnelInstance, type TunnelUsageType, } from "@pinggy/pinggy"; -import { logger } from "../logger.js"; +import { TunnelInstance, LogLevel as SdkLogLevel, type TunnelConfigurationV1, type TunnelUsageType } from "@pinggy/pinggy"; +import { logger, getLogLevel } from "../logger.js"; +import { attachTunnelLogger, detachTunnelLogger } from "../logger/tunnelLogger.js"; +import { maybeRotate } from "../logger/rotateLog.js"; import { TunnelWarningCode, Warning } from "../types.js"; import path from "node:path"; import { Worker } from "node:worker_threads"; import { fileURLToPath } from "node:url"; import CLIPrinter from "../utils/printer.js"; -import { getRandomId } from "../utils/util.js"; +import { errorMessage, getRandomId } from "../utils/util.js"; +import { getTunnelLogPath } from "../utils/configDir.js"; +import { FileServerMessage, FileServerWorkerMessage } from "../workers/fileServerMessages.js"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); +function mapToSdkLogLevel(level: string): SdkLogLevel { + if (level === "debug") return SdkLogLevel.DEBUG; + if (level === "error") return SdkLogLevel.ERROR; + return SdkLogLevel.INFO; +} + +const STATS_HISTORY_LIMIT = 100; + +export type TunnelOrigin = "app" | "cli" | "remote"; + export interface ManagedTunnel { tunnelid: string; configId: string; tunnelName?: string; + origin: TunnelOrigin; instance: TunnelInstance; tunnelConfig?: TunnelConfigurationV1; serveWorker?: Worker | null; warnings?: Warning[]; serve?: string; isStopped?: boolean; + isStopping?: boolean; createdAt?: string; startedAt?: string | null; stoppedAt?: string | null; @@ -67,7 +83,15 @@ export interface TunnelUpdateConfig extends TunnelConfigurationV1 { serve?: string; } +export class TunnelAlreadyRunningError extends Error { + constructor(public readonly configId: string, public readonly existingTunnelId: string) { + super(`Tunnel with configId "${configId}" is already running (tunnelid: ${existingTunnelId})`); + this.name = "TunnelAlreadyRunningError"; + } +} + export type StatsListener = (tunnelId: string, stats: TunnelUsageType) => void; +export type StoppedListener = (tunnelId: string) => void; export type ErrorListener = (tunnelId: string, errorMsg: string, isFatal: boolean) => void; export type PollingErrorListener = (tunnelId: string, errorMsg: string) => void; export type DisconnectListener = (tunnelId: string, error: string, messages: string[]) => void; @@ -79,7 +103,7 @@ export type ReconnectionCompletedListener = (tunnelId: string, urls: string[]) = export type ReconnectionFailedListener = (tunnelId: string, retryCnt: number) => void; export interface ITunnelManager { - createTunnel(config: TunnelCreationConfig, buildConfig?: boolean ): Promise; + createTunnel(config: TunnelCreationConfig, origin?: TunnelOrigin): Promise; startTunnel(tunnelId: string): Promise; stopTunnel(tunnelId: string): { configId: string; tunnelid: string }; stopAllTunnels(): void; @@ -97,12 +121,15 @@ export interface ITunnelManager { registerStatsListener(tunnelId: string, listener: StatsListener): Promise<[string, string]>; registerErrorListener(tunnelId: string, listener: ErrorListener): Promise; registerPollingErrorListener(tunnelId: string, listener: PollingErrorListener): Promise; - registerWorkerErrorListner(tunnelId: string, listener: TunnelWorkerErrorListner): void; + registerWorkerErrorListner(tunnelId: string, listener: TunnelWorkerErrorListner): Promise; + deregisterWorkerErrorListener(tunnelId: string, listenerId: string): void; registerStartListener(tunnelId: string, listener: StartListener): Promise; deregisterErrorListener(tunnelId: string, listenerId: string): void; deregisterPollingErrorListener(tunnelId: string, listenerId: string): void; registerDisconnectListener(tunnelId: string, listener: DisconnectListener): Promise; deregisterDisconnectListener(tunnelId: string, listenerId: string): void; + registerStoppedListener(tunnelId: string, listener: StoppedListener): Promise; + deregisterStoppedListener(tunnelId: string, listenerId: string): void; deregisterStatsListener(tunnelId: string, listenerId: string): void; getLocalserverTlsInfo(tunnelId: string): Promise; removeStoppedTunnelByTunnelId(tunnelId: string): boolean; @@ -127,6 +154,7 @@ export class TunnelManager implements ITunnelManager { private tunnelErrorListeners: Map> = new Map(); private tunnelPollingErrorListeners: Map> = new Map(); private tunnelDisconnectListeners: Map> = new Map(); + private tunnelStoppedListeners: Map> = new Map(); private tunnelWorkerErrorListeners: Map> = new Map(); private tunnelStartListeners: Map> = new Map(); private tunnelWillReconnectListeners: Map> = new Map(); @@ -157,6 +185,7 @@ export class TunnelManager implements ITunnelManager { */ async createTunnel( config: TunnelCreationConfig, + origin: TunnelOrigin = "cli", ): Promise { const { configId, tunnelid: requestedTunnelId, tunnelName, name } = config; const tunnelid = requestedTunnelId || getRandomId(); @@ -166,11 +195,17 @@ export class TunnelManager implements ITunnelManager { throw new Error("configId is required and must be a non-empty string"); } - // When buildConfig is false, use config as-is + const existing = this.tunnelsByConfigId.get(configId); + if (existing && !existing.isStopped) { + throw new TunnelAlreadyRunningError(configId, existing.tunnelid); + } + + // When buildConfig is false, use config as-is return this._createTunnelWithProcessedConfig({ configId, tunnelid, tunnelName: tunnelName || name, + origin, originalConfig: config, serve, autoReconnect, @@ -189,16 +224,27 @@ export class TunnelManager implements ITunnelManager { configId: string; tunnelid: string; tunnelName?: string; + origin: TunnelOrigin; originalConfig: TunnelConfigurationV1; serve?: string; autoReconnect: boolean; }): Promise { + const tunnelLogName = params.tunnelName || params.originalConfig?.name; + const tunnelLogPath = getTunnelLogPath(params.tunnelid, params.origin, tunnelLogName); + maybeRotate(tunnelLogPath); + attachTunnelLogger(params.tunnelid, params.origin, tunnelLogName); let instance; try { logger.debug("Creating tunnel instance with processed config", params.originalConfig); - instance = await pinggy.createTunnel(params.originalConfig); + instance = await TunnelInstance.create(params.originalConfig, { + enabled: true, + logLevel: mapToSdkLogLevel(getLogLevel()), + logFilePath: tunnelLogPath, + libpinggyLogPath: tunnelLogPath, + }); } catch (e) { logger.error("Error creating tunnel instance:", e); + detachTunnelLogger(params.tunnelid); throw e; } @@ -208,6 +254,7 @@ export class TunnelManager implements ITunnelManager { tunnelid: params.tunnelid, configId: params.configId, tunnelName: params.tunnelName, + origin: params.origin, instance, tunnelConfig: params.originalConfig, serve: params.serve, @@ -309,11 +356,16 @@ export class TunnelManager implements ITunnelManager { } logger.info("Stopping tunnel", { tunnelId, configId: managed.configId }); + // Mark before instance.stop() so the SDK disconnect callback can + // distinguish an intentional stop from a network drop and skip + // notifying disconnect listeners (which would race a reconnect modal + // into the attached TUI). + managed.isStopping = true; try { - managed.instance.stop(); + void managed.instance.stop(); if (managed.serveWorker) { logger.info("terminating serveWorker"); - managed.serveWorker.terminate(); + void managed.serveWorker.terminate(); } this.tunnelStats.delete(tunnelId); this.tunnelStatsListeners.delete(tunnelId); @@ -333,30 +385,45 @@ export class TunnelManager implements ITunnelManager { managed.warnings = managed.warnings ?? []; managed.isStopped = true; managed.stoppedAt = new Date().toISOString(); + detachTunnelLogger(tunnelId); + this.notifyStoppedListeners(tunnelId); logger.info("Tunnel stopped", { tunnelId, configId: managed.configId }); return { configId: managed.configId, tunnelid: managed.tunnelid }; } catch (error) { + managed.isStopping = false; logger.error("Failed to stop tunnel", { tunnelId, error }); throw error; } } + private notifyStoppedListeners(tunnelId: string): void { + const listeners = this.tunnelStoppedListeners.get(tunnelId); + if (!listeners) return; + for (const [id, listener] of listeners) { + try { + listener(tunnelId); + } catch (err) { + logger.debug("Error in stopped-listener callback", { listenerId: id, tunnelId, err }); + } + } + this.tunnelStoppedListeners.delete(tunnelId); + } + /** * Get all public URLs for a tunnel */ async getTunnelUrls(tunnelId: string): Promise { - try { const managed = this.tunnelsByTunnelId.get(tunnelId); - if (!managed || managed.isStopped) { + if (!managed) { logger.error(`Tunnel "${tunnelId}" not found when fetching URLs`); return []; } + if(managed.isStopped){ + logger.debug(`Skipping URL fetch for stopped tunnel`, { tunnelId }); + return []; + } const urls = await managed.instance.urls(); return urls; - } catch (error) { - logger.error("Error fetching tunnel URLs", { tunnelId, error }); - throw error; - } } /** @@ -384,6 +451,37 @@ export class TunnelManager implements ITunnelManager { } } + /** + * Re-apply the given log level to every live tunnel's worker JS logger. + * Native libpinggy level is fixed at worker init and cannot be re-pathed + * or re-levelled at runtime without restarting the tunnel. + */ + applyLogLevelToActiveTunnels(level: string): void { + const sdkLevel = mapToSdkLogLevel(level); + for (const tunnel of this.tunnelsByTunnelId.values()) { + if (tunnel.isStopped) continue; + if (tunnel.lastError?.isFatal) continue; + const logPath = getTunnelLogPath(tunnel.tunnelid, tunnel.origin, tunnel.tunnelName || tunnel.tunnelConfig?.name); + tunnel.instance.setDebugLogging(true, sdkLevel, logPath).catch((err) => { + logger.error("Failed to apply log level to tunnel", { tunnelId: tunnel.tunnelid, error: err?.message ?? err }); + }); + } + } + + /** + * Tunnel IDs whose ManagedTunnel is currently alive (not stopped, no fatal error). + * Cheap, synchronous, suitable for hot paths like /logs/paths. + */ + getActiveTunnelIds(): Set { + const ids = new Set(); + for (const tunnel of this.tunnelsByTunnelId.values()) { + if (tunnel.isStopped) continue; + if (tunnel.lastError?.isFatal) continue; + ids.add(tunnel.tunnelid); + } + return ids; + } + /** * Get status of a tunnel */ @@ -404,9 +502,11 @@ export class TunnelManager implements ITunnelManager { * Stop all tunnels */ stopAllTunnels(): void { - for (const { instance } of this.tunnelsByTunnelId.values()) { + for (const managed of this.tunnelsByTunnelId.values()) { + // Skip already-stopped tunnels + if (managed.isStopped) continue; try { - instance.stop(); + void managed.instance.stop(); } catch (e) { logger.warn("Error stopping tunnel instance", e); } @@ -528,22 +628,22 @@ export class TunnelManager implements ITunnelManager { * @returns The tunnel config * @throws Error if neither configId nor tunnelId is provided, or if tunnel is not found */ - async getTunnelConfig(configId?: string, tunnelId?: string): Promise { + getTunnelConfig(configId?: string, tunnelId?: string): Promise { if (configId) { const managed = this.tunnelsByConfigId.get(configId); if (!managed) { - throw new Error(`Tunnel with configId "${configId}" not found`); + return Promise.reject(new Error(`Tunnel with configId "${configId}" not found`)); } - return managed.instance.getConfig(); + return Promise.resolve(managed.instance.getConfig()); } if (tunnelId) { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel with tunnelId "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel with tunnelId "${tunnelId}" not found`)); } - return managed.instance.getConfig(); + return Promise.resolve(managed.instance.getConfig()); } - throw new Error(`Either configId or tunnelId must be provided`); + return Promise.reject(new Error(`Either configId or tunnelId must be provided`)); } /** @@ -570,6 +670,7 @@ export class TunnelManager implements ITunnelManager { const currentConfig = existingTunnel.tunnelConfig; const currentServe = existingTunnel.serve; const autoReconnect = existingTunnel.autoReconnect || false; + const currentOrigin = existingTunnel.origin; // Remove the existing tunnel this.tunnelsByTunnelId.delete(tunnelid); @@ -591,6 +692,7 @@ export class TunnelManager implements ITunnelManager { configId: currentConfigId, tunnelid, tunnelName, + origin: currentOrigin, originalConfig: currentConfig!, serve: currentServe, autoReconnect, @@ -647,12 +749,13 @@ export class TunnelManager implements ITunnelManager { const currentTunnelName = existingTunnel.tunnelName; const currentServe = existingTunnel.serve; const currentAutoReconnect = existingTunnel.autoReconnect || false; + const currentOrigin = existingTunnel.origin; const requestedServe = this.resolveServePath(newConfig); try { // Stop the existing tunnel if running if (!isStopped) { - existingTunnel.instance.stop(); + void existingTunnel.instance.stop(); } // Remove the old tunnel @@ -679,6 +782,7 @@ export class TunnelManager implements ITunnelManager { configId: configId, tunnelid: currentTunnelId, tunnelName: effectiveTunnelName, + origin: currentOrigin, originalConfig: mergedBaseConfig, serve: effectiveServe, autoReconnect: currentAutoReconnect, @@ -697,10 +801,10 @@ export class TunnelManager implements ITunnelManager { return newTunnel; - } catch (error: any) { + } catch (error) { logger.error("Error updating tunnel configuration", { configId: configId, - error: error instanceof Error ? error.message : String(error) + error: errorMessage(error) }); // If anything fails during the update, try to restore the previous state try { @@ -708,6 +812,7 @@ export class TunnelManager implements ITunnelManager { configId: currentTunnelConfigId, tunnelid: currentTunnelId, tunnelName: currentTunnelName, + origin: currentOrigin, originalConfig: currentTunnelConfig, serve: currentServe, autoReconnect: currentAutoReconnect, @@ -717,12 +822,12 @@ export class TunnelManager implements ITunnelManager { } logger.warn("Restored original tunnel configuration after update failure", { currentTunnelId, - error: error instanceof Error ? error.message : 'Unknown error' + error: errorMessage(error) }); - } catch (restoreError: any) { + } catch (restoreError) { logger.error("Failed to restore original tunnel configuration", { currentTunnelId, - error: restoreError instanceof Error ? restoreError.message : 'Unknown error' + error: errorMessage(restoreError) }); } // Re-throw the original error @@ -812,11 +917,11 @@ export class TunnelManager implements ITunnelManager { * * @throws {Error} When the specified tunnelId does not exist */ - async registerStatsListener(tunnelId: string, listener: StatsListener): Promise<[string, string]> { + registerStatsListener(tunnelId: string, listener: StatsListener): Promise<[string, string]> { // Verify tunnel exists const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } // Initialize listeners map for this tunnel if it doesn't exist @@ -828,13 +933,13 @@ export class TunnelManager implements ITunnelManager { tunnelListeners.set(listenerId, listener); logger.info("Stats listener registered for tunnel", { tunnelId, listenerId }); - return [listenerId, tunnelId]; + return Promise.resolve([listenerId, tunnelId]); } - async registerErrorListener(tunnelId: string, listener: ErrorListener): Promise { + registerErrorListener(tunnelId: string, listener: ErrorListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelErrorListeners.has(tunnelId)) { @@ -845,13 +950,13 @@ export class TunnelManager implements ITunnelManager { tunnelErrorListeners.set(listenerId, listener); logger.info("Error listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerPollingErrorListener(tunnelId: string, listener: PollingErrorListener): Promise { + registerPollingErrorListener(tunnelId: string, listener: PollingErrorListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelPollingErrorListeners.has(tunnelId)) { @@ -861,15 +966,15 @@ export class TunnelManager implements ITunnelManager { const listenerId = getRandomId(); this.tunnelPollingErrorListeners.get(tunnelId)!.set(listenerId, listener); logger.info("Polling error listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerDisconnectListener(tunnelId: string, listener: DisconnectListener): Promise { + registerDisconnectListener(tunnelId: string, listener: DisconnectListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelDisconnectListeners.has(tunnelId)) { @@ -881,13 +986,13 @@ export class TunnelManager implements ITunnelManager { tunnelDisconnectListeners.set(listenerId, listener); logger.info("Disconnect listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerWorkerErrorListner(tunnelId: string, listener: TunnelWorkerErrorListner): Promise { + registerWorkerErrorListner(tunnelId: string, listener: TunnelWorkerErrorListner): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelWorkerErrorListeners.has(tunnelId)) { @@ -898,12 +1003,30 @@ export class TunnelManager implements ITunnelManager { const tunnelWorkerErrorListner = this.tunnelWorkerErrorListeners.get(tunnelId); tunnelWorkerErrorListner?.set(listenerId, listener); logger.info("TunnelWorker error listener registered for tunnel", { tunnelId, listenerId }); + return Promise.resolve(listenerId); } - async registerStartListener(tunnelId: string, listener: StartListener): Promise { + deregisterWorkerErrorListener(tunnelId: string, listenerId: string): void { + const listeners = this.tunnelWorkerErrorListeners.get(tunnelId); + if (!listeners) { + logger.warn("No worker error listeners found for tunnel", { tunnelId }); + return; + } + const removed = listeners.delete(listenerId); + if (removed) { + logger.info("Worker error listener deregistered", { tunnelId, listenerId }); + if (listeners.size === 0) { + this.tunnelWorkerErrorListeners.delete(tunnelId); + } + } else { + logger.warn("Attempted to deregister non-existent worker error listener", { tunnelId, listenerId }); + } + } + + registerStartListener(tunnelId: string, listener: StartListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelStartListeners.has(tunnelId)) { @@ -915,13 +1038,13 @@ export class TunnelManager implements ITunnelManager { listeners.set(listenerId, listener); logger.info("Start listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerWillReconnectListener(tunnelId: string, listener: WillReconnectListener): Promise { + registerWillReconnectListener(tunnelId: string, listener: WillReconnectListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelWillReconnectListeners.has(tunnelId)) { @@ -932,13 +1055,13 @@ export class TunnelManager implements ITunnelManager { this.tunnelWillReconnectListeners.get(tunnelId)!.set(listenerId, listener); logger.info("WillReconnect listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerReconnectingListener(tunnelId: string, listener: ReconnectingListener): Promise { + registerReconnectingListener(tunnelId: string, listener: ReconnectingListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelReconnectingListeners.has(tunnelId)) { @@ -949,13 +1072,13 @@ export class TunnelManager implements ITunnelManager { this.tunnelReconnectingListeners.get(tunnelId)!.set(listenerId, listener); logger.info("Reconnecting listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerReconnectionCompletedListener(tunnelId: string, listener: ReconnectionCompletedListener): Promise { + registerReconnectionCompletedListener(tunnelId: string, listener: ReconnectionCompletedListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelReconnectionCompletedListeners.has(tunnelId)) { @@ -966,13 +1089,13 @@ export class TunnelManager implements ITunnelManager { this.tunnelReconnectionCompletedListeners.get(tunnelId)!.set(listenerId, listener); logger.info("ReconnectionCompleted listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } - async registerReconnectionFailedListener(tunnelId: string, listener: ReconnectionFailedListener): Promise { + registerReconnectionFailedListener(tunnelId: string, listener: ReconnectionFailedListener): Promise { const managed = this.tunnelsByTunnelId.get(tunnelId); if (!managed) { - throw new Error(`Tunnel "${tunnelId}" not found`); + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); } if (!this.tunnelReconnectionFailedListeners.has(tunnelId)) { @@ -982,7 +1105,7 @@ export class TunnelManager implements ITunnelManager { const listenerId = getRandomId(); this.tunnelReconnectionFailedListeners.get(tunnelId)!.set(listenerId, listener); logger.info("ReconnectionFailed listener registered for tunnel", { tunnelId, listenerId }); - return listenerId; + return Promise.resolve(listenerId); } /** @@ -1063,6 +1186,28 @@ export class TunnelManager implements ITunnelManager { } } + registerStoppedListener(tunnelId: string, listener: StoppedListener): Promise { + const managed = this.tunnelsByTunnelId.get(tunnelId); + if (!managed) { + return Promise.reject(new Error(`Tunnel "${tunnelId}" not found`)); + } + if (!this.tunnelStoppedListeners.has(tunnelId)) { + this.tunnelStoppedListeners.set(tunnelId, new Map()); + } + const listenerId = getRandomId(); + this.tunnelStoppedListeners.get(tunnelId)!.set(listenerId, listener); + return Promise.resolve(listenerId); + } + + deregisterStoppedListener(tunnelId: string, listenerId: string): void { + const listeners = this.tunnelStoppedListeners.get(tunnelId); + if (!listeners) return; + listeners.delete(listenerId); + if (listeners.size === 0) { + this.tunnelStoppedListeners.delete(tunnelId); + } + } + deregisterWillReconnectListener(tunnelId: string, listenerId: string): void { const listeners = this.tunnelWillReconnectListeners.get(tunnelId); if (!listeners) { @@ -1162,7 +1307,7 @@ export class TunnelManager implements ITunnelManager { */ private setupStatsCallback(tunnelId: string, managed: ManagedTunnel): void { try { - const callback = (usage: Record) => { + const callback = (usage: Record) => { this.updateStats(tunnelId, usage); }; @@ -1292,6 +1437,13 @@ export class TunnelManager implements ITunnelManager { managedTunnel.stoppedAt = new Date().toISOString(); } + // SDK fires disconnect synchronously from instance.stop(). + // When stopTunnel() set isStopping, the "stopped" event will + // be emitted instead — don't double-notify subscribers. + if (managedTunnel?.isStopping) { + return; + } + const listeners = this.tunnelDisconnectListeners.get(tunnelId); if (!listeners) { return; @@ -1507,16 +1659,15 @@ export class TunnelManager implements ITunnelManager { /** * Updates the stored stats for a tunnel and notifies all registered listeners. */ - private updateStats(tunnelId: string, rawUsage: Record): void { + private updateStats(tunnelId: string, rawUsage: Record): void { try { // Normalize the stats const normalizedStats = this.normalizeStats(rawUsage); - // get existing stats - const existingStats = this.tunnelStats.get(tunnelId) || []; - // Append the new stats to existing stats - const updatedStats = [...existingStats, normalizedStats]; - // Store the latest stats + const existingStats = this.tunnelStats.get(tunnelId) ?? []; + const updatedStats = existingStats.length >= STATS_HISTORY_LIMIT + ? [...existingStats.slice(existingStats.length - STATS_HISTORY_LIMIT + 1), normalizedStats] + : [...existingStats, normalizedStats]; this.tunnelStats.set(tunnelId, updatedStats); // Notify all registered listeners for this specific tunnel @@ -1543,7 +1694,7 @@ export class TunnelManager implements ITunnelManager { /** * Normalizes raw usage data from the SDK into a consistent TunnelStats format. */ - private normalizeStats(rawStats: Record): TunnelUsageType { + private normalizeStats(rawStats: Record): TunnelUsageType { const elapsed = this.parseNumber(rawStats.elapsedTime ?? 0); const liveConns = this.parseNumber(rawStats.numLiveConnections ?? 0); const totalConns = this.parseNumber(rawStats.numTotalConnections ?? 0); @@ -1561,7 +1712,7 @@ export class TunnelManager implements ITunnelManager { }; } - private parseNumber(value: any): number { + private parseNumber(value: unknown): number { const parsed = typeof value === 'number' ? value : parseInt(String(value), 10); return isNaN(parsed) ? 0 : parsed; } @@ -1596,25 +1747,25 @@ export class TunnelManager implements ITunnelManager { }, }); - staticServerWorker.on("message", (msg) => { + staticServerWorker.on("message", (msg: FileServerWorkerMessage) => { switch (msg.type) { - case "started": + case FileServerMessage.Started: logger.info("Static file server started", { dir: managed.serve, port: msg.portNum }); break; - case "warning": + case FileServerMessage.Warning: if (msg.code === "INVALID_TUNNEL_SERVE_PATH") { managed.warnings = managed.warnings ?? []; - managed.warnings.push({ code: msg.code, message: msg.message }); + managed.warnings.push({ code: msg.code as TunnelWarningCode, message: msg.message }); } CLIPrinter.warn(msg.message); break; - case "error": + case FileServerMessage.Error: managed.warnings = managed.warnings ?? []; managed.warnings.push({ code: "UNKNOWN_WARNING" as TunnelWarningCode, - message: msg.message, + message: msg.error, }); break; } diff --git a/src/types.ts b/src/types.ts index c253ca9..9722745 100644 --- a/src/types.ts +++ b/src/types.ts @@ -84,7 +84,7 @@ export type ErrorCodeType = | "REMOTE_MANAGEMENT_NOT_RUNNING" | "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED"; -export const ErrorCode: Record = { +export const ErrorCode = { InvalidRequestMethodError: "INVALID_REQUEST_METHOD", InvalidRequestBodyError: "COULD_NOT_READ_BODY", InternalServerError: "INTERNAL_SERVER_ERROR", @@ -96,7 +96,7 @@ export const ErrorCode: Record = { RemoteManagementAlreadyRunning: "REMOTE_MANAGEMENT_ALREADY_RUNNING", RemoteManagementNotRunning: "REMOTE_MANAGEMENT_NOT_RUNNING", RemoteManagementDeserializationFailed: "REMOTE_MANAGEMENT_DESERIALIZATION_FAILED", -} as const; +} as const satisfies Record; export interface ErrorResponse { code: ErrorCodeType; diff --git a/src/utils/FileServer.ts b/src/utils/FileServer.ts index dc2fb78..3dccba1 100644 --- a/src/utils/FileServer.ts +++ b/src/utils/FileServer.ts @@ -88,10 +88,10 @@ export async function startFileServer(dirPath: string, port = 8080) { const type = mime.getType(extname(filePath)) || "application/octet-stream"; res.writeHead(200, { "Content-Type": type }); res.end(content); - } catch (err: any) { + } catch (err) { logger.debug("Error in handling request", err) res.statusCode = 500; - res.end(`Internal Server Error: ${err.message}`); + res.end(`Internal Server Error: ${err instanceof Error ? err.message : String(err)}`); } }); diff --git a/src/utils/configDir.ts b/src/utils/configDir.ts index 25ea06c..2736485 100644 --- a/src/utils/configDir.ts +++ b/src/utils/configDir.ts @@ -35,3 +35,102 @@ export function ensureTunnelConfigDir(): string { fs.mkdirSync(dir, { recursive: true }); return dir; } + +/** + * Returns the path to the daemon info file (daemon.json). + * Contains port + PID so the foreground CLI can find the running daemon. + */ +export function getDaemonInfoPath(): string { + return path.join(getPinggyConfigDir(), "daemon.json"); +} + +/** + * Returns the path to the daemon config file (daemon-config.json). + * Holds settings that must survive a clean shutdown (e.g. log level), unlike + * daemon-state.json which is per-run crash-recovery state. + */ +export function getDaemonConfigPath(): string { + return path.join(getPinggyConfigDir(), "daemon-config.json"); +} + +/** + * Returns the OS-conventional log directory for the Pinggy CLI. + * Uses a "Pinggy-CLI" namespace + * - Linux: $XDG_STATE_HOME/pinggy-cli/logs (default ~/.local/state/pinggy-cli/logs) + * - macOS: ~/Library/Logs/Pinggy-CLI + * - Windows: %LOCALAPPDATA%/Pinggy-CLI/Logs + */ +export function getPinggyLogDir(): string { + const platform = os.platform(); + if (platform === "win32") { + const localAppData = process.env.LOCALAPPDATA || path.join(os.homedir(), "AppData", "Local"); + return path.join(localAppData, "Pinggy-CLI", "Logs"); + } + if (platform === "darwin") { + return path.join(os.homedir(), "Library", "Logs", "Pinggy-CLI"); + } + // Linux / other + const stateHome = process.env.XDG_STATE_HOME || path.join(os.homedir(), ".local", "state"); + return path.join(stateHome, "pinggy-cli", "logs"); +} + +export function ensurePinggyLogDir(): string { + const dir = getPinggyLogDir(); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getTunnelLogDir(): string { + return path.join(getPinggyLogDir(), "tunnels"); +} + +export function ensureTunnelLogDir(): string { + const dir = getTunnelLogDir(); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getLibpinggyLogDir(): string { + return path.join(getPinggyLogDir(), "libpinggy"); +} + +export function ensureLibpinggyLogDir(): string { + const dir = getLibpinggyLogDir(); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} + +export function getLibpinggyLogPath(): string { + return path.join(getLibpinggyLogDir(), "libpinggy.log"); +} + +/** + * Returns the log file path for a tunnel. + * Named tunnels: __.log (stable across restarts) + * Ad-hoc tunnels: __.log + * Origin is one of: "app" | "cli" | "remote". + */ +export function getTunnelLogPath(tunnelId: string, origin: string, name?: string): string { + const dir = getTunnelLogDir(); + if (name) { + const sanitized = name.replace(/[^a-zA-Z0-9_-]/g, "_"); + return path.join(dir, `${origin}__${sanitized}.log`); + } + return path.join(dir, `${origin}__${tunnelId}.log`); +} + +/** + * Returns the path to the daemon log file. + */ +export function getDaemonLogPath(): string { + return path.join(getPinggyLogDir(), "daemon.log"); +} + +/** + * Ensures the base pinggy config directory exists. + */ +export function ensurePinggyConfigDir(): string { + const dir = getPinggyConfigDir(); + fs.mkdirSync(dir, { recursive: true }); + return dir; +} diff --git a/src/utils/daemonLostMessage.ts b/src/utils/daemonLostMessage.ts new file mode 100644 index 0000000..1f16989 --- /dev/null +++ b/src/utils/daemonLostMessage.ts @@ -0,0 +1,10 @@ +import { DaemonLostReason } from "../daemon/tunnelClient.js"; + +export function daemonLostMessage(reason: DaemonLostReason, detail?: string): string { + switch (reason) { + case "dead": return "Daemon process is no longer running. Tunnel stopped."; + case "respawned": return `Daemon was restarted${detail ? ` (${detail})` : ""}. The previous session is gone.`; + case "hung": return "Daemon stopped responding after retries. Tunnel stopped."; + case "heartbeat": return "Daemon stopped responding to health checks. Tunnel stopped."; + } +} \ No newline at end of file diff --git a/src/utils/helpMessages.ts b/src/utils/helpMessages.ts new file mode 100644 index 0000000..6429bcc --- /dev/null +++ b/src/utils/helpMessages.ts @@ -0,0 +1,42 @@ + +export function printDaemonHelp(): void { + console.log("\nUsage: pinggy daemon "); + console.log(" pinggy d \n"); + console.log("Commands:"); + console.log(" start Start the daemon process"); + console.log(" stop Stop the daemon (stops all tunnels)"); + console.log(" status Show daemon PID and uptime"); + console.log("Tunnel operations:"); + console.log(" pinggy ps List running tunnels"); + console.log(" pinggy stop Stop a specific tunnel"); + console.log(" pinggy attach Re-attach TUI to a tunnel\n"); +} + +export function printLogHelp(): void { + console.log("\nUsage: pinggy log [options]\n"); + console.log("Commands:"); + console.log(" level Print current log level"); + console.log(" level debug|info|error Set log level"); + console.log(" path Print daemon log path"); + console.log(" path Print tunnel log path\n"); +} +export function printConfigHelp(): void { + console.log("\nUsage: pinggy config [name] [options]\n"); + console.log("Commands:"); + console.log(" list List all saved configs"); + console.log(" show Show config details"); + console.log(" save [tunnel flags] Save a tunnel config"); + console.log(" update [tunnel flags] Update a saved config"); + console.log(" delete Delete a saved config"); + console.log(" auto Enable auto-start"); + console.log(" noauto Disable auto-start\n"); +} + +export function printStartHelp(): void { + console.log("\nUsage: pinggy start [options]\n"); + console.log("Examples:"); + console.log(" pinggy start my-tunnel Start a saved tunnel"); + console.log(" pinggy start my-tunnel -l 4000 Start with override"); + console.log(" pinggy start tunnela tunnelb Start multiple tunnels"); + console.log(" pinggy start --all Start all auto-start tunnels\n"); +} \ No newline at end of file diff --git a/src/utils/printer.ts b/src/utils/printer.ts index 301eaa6..fdbd58a 100644 --- a/src/utils/printer.ts +++ b/src/utils/printer.ts @@ -1,6 +1,8 @@ import pico from "picocolors"; import { startSpinner, stopSpinnerSuccess as stopSpinnerSuccessCustom, stopSpinnerFail as stopSpinnerFailCustom } from "../tui/spinner/spinner.js"; +type CLIError = Error & { code?: string; option?: string; value?: string }; + interface CLIErrorDefinition { match: (err: unknown) => boolean; message: (err: unknown) => string; @@ -8,29 +10,29 @@ interface CLIErrorDefinition { class CLIPrinter { - private static isCLIError(err: unknown): err is Error & { code?: string; option?: string; value?: string } { + private static isCLIError(err: unknown): err is CLIError { return err instanceof Error; } private static errorDefinitions: CLIErrorDefinition[] = [ { match: (err) => this.isCLIError(err) && err.code === "ERR_PARSE_ARGS_UNKNOWN_OPTION", message: (err) => { - const match = /Unknown option '(.+?)'/.exec((err as any).message); + const match = /Unknown option '(.+?)'/.exec((err as CLIError).message); const option = match ? match[1] : '(unknown)'; return `Unknown option '${option}'. Please check your command or use pinggy -h for guidance.`; }, }, { match: (err) => this.isCLIError(err) && err.code === "ERR_PARSE_ARGS_MISSING_OPTION_VALUE", - message: (err) => `Missing required argument for option '${(err as any).option}'.`, + message: (err) => `Missing required argument for option '${(err as CLIError).option}'.`, }, { match: (err) => this.isCLIError(err) && err.code === "ERR_PARSE_ARGS_INVALID_OPTION_VALUE", - message: (err) => `Invalid argument'${(err as any).message}'.`, + message: (err) => `Invalid argument'${(err as CLIError).message}'.`, }, { match: (err) => this.isCLIError(err) && err.code === "ENOENT", - message: (err) => `File or directory not found: ${(err as any).message}`, + message: (err) => `File or directory not found: ${(err as CLIError).message}`, }, { match: () => true, // fallback @@ -38,7 +40,7 @@ class CLIPrinter { }, ]; - static print(message: string, ...args: any[]) { + static print(message: string, ...args: unknown[]) { console.log(message, ...args); } @@ -71,7 +73,7 @@ class CLIPrinter { console.log(pico.green(pico.bold(" ✔ Success:")), pico.green(message)); } - static async info(message: string) { + static info(message: string) { console.log(pico.blue(message)); } diff --git a/src/utils/util.ts b/src/utils/util.ts index 43d0014..0db608e 100644 --- a/src/utils/util.ts +++ b/src/utils/util.ts @@ -13,6 +13,10 @@ export function isValidPort(p: number): boolean { return Number.isInteger(p) && p > 0 && p < 65536; } +export function errorMessage(err: unknown): string { + return err instanceof Error ? err.message : String(err); +} + const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -26,3 +30,28 @@ export function getVersion(): string { return ''; } } + +type ForwardingItem = { + address?: string; + localDomain?: string; + localPort?: string | number; +}; + +type LocalAddressConfig = { + forwarding?: string | ForwardingItem[] | null; + localAddress?: string; +}; + +export function getLocalAddress(config: LocalAddressConfig | null | undefined): string { + if (!config) return "-"; + if (config.forwarding) { + if (typeof config.forwarding === "string") return config.forwarding; + if (Array.isArray(config.forwarding) && config.forwarding.length > 0) { + const f = config.forwarding[0]; + if (f.address) return f.address; + if (f.localDomain && f.localPort) return `${f.localDomain}:${f.localPort}`; + } + } + if (config.localAddress) return config.localAddress; + return "-"; +} diff --git a/src/workers/fileServerMessages.ts b/src/workers/fileServerMessages.ts new file mode 100644 index 0000000..4113b52 --- /dev/null +++ b/src/workers/fileServerMessages.ts @@ -0,0 +1,11 @@ +export const FileServerMessage = { + Started: "started", + Warning: "warning", + Error: "error", +} as const; +export type FileServerMessageType = typeof FileServerMessage[keyof typeof FileServerMessage]; + +export type FileServerWorkerMessage = + | { type: typeof FileServerMessage.Started; portNum?: number } + | { type: typeof FileServerMessage.Warning; message: string; code?: string } + | { type: typeof FileServerMessage.Error; error: string; code?: string }; diff --git a/src/workers/file_serve_worker.ts b/src/workers/file_serve_worker.ts index 90795d6..36c6108 100644 --- a/src/workers/file_serve_worker.ts +++ b/src/workers/file_serve_worker.ts @@ -1,8 +1,13 @@ import { parentPort, workerData } from "worker_threads"; import { FileServerError, startFileServer } from "../utils/FileServer.js"; import { logger } from "../logger.js"; +import { FileServerMessage, FileServerWorkerMessage } from "./fileServerMessages.js"; -(async () => { +function post(msg: FileServerWorkerMessage): void { + parentPort?.postMessage(msg); +} + +void (async () => { try { const { dir, forwarding } = workerData; logger.debug("file_serve_worker received workerData", { dir, forwarding: JSON.stringify(forwarding) }); @@ -21,11 +26,11 @@ import { logger } from "../logger.js"; const result = await startFileServer(dir, portNum); logger.info("file_serve_worker static file server started", { dir, port: portNum ?? 8080 }); - parentPort?.postMessage({ type: "started", portNum }); + post({ type: FileServerMessage.Started, portNum }); if (result.hasInvalidPath && result.error) { logger.warn("file_serve_worker invalid path warning", { message: result.error.message, code: result.error.code }); - parentPort?.postMessage({ - type: "warning", + post({ + type: FileServerMessage.Warning, message: result.error.message, code: result.error.code, }); @@ -36,11 +41,11 @@ import { logger } from "../logger.js"; } catch (err) { console.log(err); if (err instanceof FileServerError) { - parentPort?.postMessage({ type: "error", error: err.message, code: err.code }); + post({ type: FileServerMessage.Error, error: err.message, code: err.code }); } else if (err instanceof Error) { - parentPort?.postMessage({ type: "error", error: err.message }); + post({ type: FileServerMessage.Error, error: err.message }); } else { - parentPort?.postMessage({ type: "error", error: String(err) }); + post({ type: FileServerMessage.Error, error: String(err) }); } logger.debug("Error in FileServer thread", err); process.exit(1); diff --git a/test/e2e/README.md b/test/e2e/README.md index e2893a7..a94a399 100644 --- a/test/e2e/README.md +++ b/test/e2e/README.md @@ -21,6 +21,33 @@ CI runs the same command across 6 platforms in `.github/workflows/e2e-test.yml`. ## What gets tested +### Daemon lifecycle + +| Case | Verifies | +|---|---| +| `daemon-start-stop` | `pinggy daemon start` writes `daemon.json` with a live PID and reachable port. `pinggy daemon stop` removes the file and exits the PID. | +| `daemon-status` | `pinggy daemon status` reports PID/port matching `daemon.json` and uptime grows between calls | +| `daemon-stale-pid` | A pre-existing `daemon.json` with a dead PID is detected and removed on next status query | + +### Config CRUD + +| Case | Verifies | +|---|---| +| `config-save-list` | `config save -l ` writes `_.json` and `config list`/`show` surface it | +| `config-update` | `config update` mutates `tunnelConfig` and bumps `updatedAt` without changing `configId`/`createdAt` | +| `config-delete` | `config delete` removes the file. Second delete reports missing config | +| `config-name-validation` | Reserved names (`ps`), bad chars (`foo!bar`), and overlong names (>128) all exit nonzero with no file written | +| `config-auto-toggle` | `config auto`/`noauto` flip `autoStart` on disk. `config save --auto` sets it on create | + +### IPC direct + +| Case | Verifies | +|---|---| +| `ipc-http` | `GET /ping`, `GET /tunnels`, `GET /config/tunnel-logging`, `GET /logs/paths`, `POST /shutdown` over HTTP to the daemon port | +| `ipc-loglevel` | `POST /loglevel` persists to `daemon-config.json` and survives daemon restart | + +### Legacy single-tunnel flags + | Case | Verifies | |---|---| | `serve` | `--serve ` static file mode returns the expected HTML over the tunnel | @@ -35,6 +62,29 @@ CI runs the same command across 6 platforms in `.github/workflows/e2e-test.yml`. | `config-roundtrip` | `--saveconf` writes a file. A second run with `--conf` works | | `debugger-ws` | `/introspec/websocket` emits a `{req, res}` frame for a tunneled request | +### Subcommand-driven tunnels (via daemon) + +| Case | Verifies | +|---|---| +| `start-background` | `pinggy start -b` creates a detached tunnel routed through the daemon. URL serves the echo backend. `pinggy stop ` removes it | +| `ps-output` | `pinggy ps` lists two detached tunnels with `running` status, names, and URLs | +| `stop-resolution` | `pinggy stop` resolves by exact name, by 8-char ID prefix, and reports clearly on miss | +| `restart` | `pinggy restart ` preserves `configId`, the tunnel re-enters `running` state, and the new URL is reachable | + +### Foreground/detached lifecycle + +| Case | Verifies | +|---|---| +| `foreground-grace-stops` | A foreground tunnel (no `-b`) reports `mode: "foreground"` in `/tunnels` and is absent from `daemon-state.json`. After SIGKILL of the owning CLI, the daemon stops the tunnel within the 5s grace period | +| `detached-survives-cli-exit` | A `-b` tunnel reports `mode: "detached"` in `/tunnels` and `daemon-state.json`. After 8s (well past the grace window) it is still running and still serves the echo backend | + +### Crash recovery & clean shutdown + +| Case | Verifies | +|---|---| +| `clean-shutdown-clears-state` | `daemon-state.json` records the running tunnel, gets emptied on `pinggy stop`, and is deleted entirely on `daemon stop` | +| `crash-recovery-detached` | SIGKILL on the daemon PID leaves `daemon-state.json` populated. Next `daemon start` restores the detached tunnel by name | + ## debugger-ws in detail The web debugger (configured with `-L:localhost:`) exposes two endpoints on that local port: @@ -65,13 +115,25 @@ test/e2e/ echo-udp.cjs # UDP datagram echo backend lib/ cli.cjs # process lifecycle, log/URL tunnel detection + sandbox.cjs # per-suite HOME/XDG/APPDATA env override + daemon.cjs # daemon fixture, IPC client, subcommand runner framework.cjs # state, runCase, withTunnel, withEcho, buildArgs, helpers cases/.cjs # one file per test case ``` The `lib/cli.cjs` module spawns the binary, watches the log file for `Tunnel started {...,"urls":[...]}`, and falls back to `/urls` polling. Cross-platform process termination uses `taskkill /T /F` on Windows and `SIGTERM` then `SIGKILL` on Unix. -The `lib/framework.cjs` module owns shared state (workdir, debugger-port counter, public IP parsed from URL) and exports the helpers cases compose: +The `lib/sandbox.cjs` module redirects every spawned CLI's view of the user's home and config directories into a per-suite tmp tree (`$workDir/home`). It overrides `HOME`, `USERPROFILE`, `XDG_CONFIG_HOME`, `XDG_STATE_HOME`, `APPDATA`, `LOCALAPPDATA` so the daemon writes `daemon.json`, `daemon-state.json`, saved configs, and logs into the sandbox instead of the developer's real `~/.config/pinggy/`. + +The `lib/daemon.cjs` module exposes the daemon fixture used by all subcommand-driven tests: + +- `startDaemon()` runs `pinggy daemon start` and waits for `daemon.json` to appear with a live PID. +- `stopDaemon()` runs `pinggy daemon stop`, polls for the PID to exit, and force-kills on timeout. +- `runSubcommand(args, opts)` spawns a one-shot CLI invocation, captures stdout/stderr (ANSI-stripped), and returns `{code, stdout, stderr, combined}`. +- `ipcRequest(method, route, body)` is a tiny fetch wrapper bound to the daemon's HTTP port. Used by the `ipc-*` cases. +- `readDaemonInfo()`, `readDaemonState()`, `readDaemonConfig()` parse the JSON files the daemon writes. + +The `lib/framework.cjs` module owns shared state (workdir, sandbox, debugger-port counter, public IP parsed from URL) and re-exports both the tunnel-flag helpers (`withTunnel`, `withEcho`, `buildArgs`) and the daemon helpers above so cases need only one require. - `withTunnel({ name, build }, fn)` spawns the CLI with `buildArgs(build)`, waits for URLs, calls `fn({ urls, dbg, log })`, kills the process on exit. - `withEcho(kind, fn)` starts an HTTP / TCP / UDP echo backend, calls `fn(echo)`, stops the backend. @@ -80,6 +142,7 @@ The `lib/framework.cjs` module owns shared state (workdir, debugger-port counter ## Add a new case + 1. Create `cases/.cjs`: ```js @@ -101,8 +164,6 @@ module.exports = { }; ``` -2. Register it in `run.cjs`: - ```js const cases = [ // ... diff --git a/test/e2e/cases/clean-shutdown-clears-state.cjs b/test/e2e/cases/clean-shutdown-clears-state.cjs new file mode 100644 index 0000000..18b42a6 --- /dev/null +++ b/test/e2e/cases/clean-shutdown-clears-state.cjs @@ -0,0 +1,51 @@ +const fs = require('fs'); +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + readDaemonState, + withEcho, + sleep, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'clean-shutdown-clears-state', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + let stopped = false; + try { + const save = await runSubcommand(['config', 'save', 'cleancfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const start = await runSubcommand(['start', 'cleancfg', '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start exit=${start.code}: ${start.combined.slice(0, 600)}`); + + const state = readDaemonState(); + if (!state || state.tunnels.length !== 1) { + throw new Error(`expected 1 tunnel in state; got ${state ? state.tunnels.length : 'no state'}`); + } + + const stop = await runSubcommand(['stop', 'cleancfg']); + if (stop.code !== 0) throw new Error(`stop exit=${stop.code}: ${stop.combined.slice(0, 400)}`); + + await sleep(500); + const afterStop = readDaemonState(); + if (!afterStop || afterStop.tunnels.length !== 0) { + throw new Error(`expected empty tunnels after stop; got ${JSON.stringify(afterStop)}`); + } + + await stopDaemon(); + stopped = true; + + if (fs.existsSync(sandbox.daemonStatePath())) { + throw new Error(`daemon-state.json should be removed on clean shutdown`); + } + } finally { + if (!stopped) await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/config-auto-toggle.cjs b/test/e2e/cases/config-auto-toggle.cjs new file mode 100644 index 0000000..e83f789 --- /dev/null +++ b/test/e2e/cases/config-auto-toggle.cjs @@ -0,0 +1,40 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + runSubcommand, +} = require('../lib/framework.cjs'); + +function readConfig(name) { + const dir = sandbox.tunnelsConfigDir(); + const match = fs.readdirSync(dir).find((f) => f.startsWith(name + '_') && f.endsWith('.json')); + return match ? JSON.parse(fs.readFileSync(path.join(dir, match), 'utf-8')) : null; +} + +module.exports = { + name: 'config-auto-toggle', + async run() { + sandbox.reset(); + + const save = await runSubcommand(['config', 'save', 'autocfg', '-l', '3000']); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + let cfg = readConfig('autocfg'); + if (cfg.autoStart !== false) throw new Error(`initial autoStart should be false, got ${cfg.autoStart}`); + + const on = await runSubcommand(['config', 'auto', 'autocfg']); + if (on.code !== 0) throw new Error(`auto on exit=${on.code}: ${on.combined.slice(0, 400)}`); + cfg = readConfig('autocfg'); + if (cfg.autoStart !== true) throw new Error(`autoStart should be true after auto; got ${cfg.autoStart}`); + + const off = await runSubcommand(['config', 'noauto', 'autocfg']); + if (off.code !== 0) throw new Error(`noauto exit=${off.code}: ${off.combined.slice(0, 400)}`); + cfg = readConfig('autocfg'); + if (cfg.autoStart !== false) throw new Error(`autoStart should be false after noauto; got ${cfg.autoStart}`); + + const saveAuto = await runSubcommand(['config', 'save', 'autocfg2', '-l', '3001', '--auto']); + if (saveAuto.code !== 0) throw new Error(`save --auto failed: ${saveAuto.combined.slice(0, 400)}`); + const cfg2 = readConfig('autocfg2'); + if (cfg2.autoStart !== true) throw new Error(`save --auto did not set autoStart=true; got ${cfg2.autoStart}`); + }, +}; diff --git a/test/e2e/cases/config-delete.cjs b/test/e2e/cases/config-delete.cjs new file mode 100644 index 0000000..339d007 --- /dev/null +++ b/test/e2e/cases/config-delete.cjs @@ -0,0 +1,34 @@ +const fs = require('fs'); +const { + sandbox, + runSubcommand, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'config-delete', + async run() { + sandbox.reset(); + + const save = await runSubcommand(['config', 'save', 'delcfg', '-l', '3000']); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const filesBefore = fs.readdirSync(sandbox.tunnelsConfigDir()); + if (!filesBefore.some((f) => f.startsWith('delcfg_'))) { + throw new Error(`save did not create file: ${filesBefore.join(', ')}`); + } + + const del = await runSubcommand(['config', 'delete', 'delcfg']); + if (del.code !== 0) throw new Error(`delete exit=${del.code}: ${del.combined.slice(0, 400)}`); + if (!/deleted/i.test(del.combined)) throw new Error(`expected "deleted" message: ${del.combined.slice(0, 400)}`); + + const filesAfter = fs.readdirSync(sandbox.tunnelsConfigDir()); + if (filesAfter.some((f) => f.startsWith('delcfg_'))) { + throw new Error(`config file still present after delete: ${filesAfter.join(', ')}`); + } + + const del2 = await runSubcommand(['config', 'delete', 'delcfg']); + if (!/No config found/i.test(del2.combined)) { + throw new Error(`second delete should report missing config; got: ${del2.combined.slice(0, 400)}`); + } + }, +}; diff --git a/test/e2e/cases/config-name-validation.cjs b/test/e2e/cases/config-name-validation.cjs new file mode 100644 index 0000000..6a363b4 --- /dev/null +++ b/test/e2e/cases/config-name-validation.cjs @@ -0,0 +1,45 @@ +const fs = require('fs'); +const { + sandbox, + runSubcommand, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'config-name-validation', + async run() { + sandbox.reset(); + + const reserved = await runSubcommand(['config', 'save', 'ps', '-l', '3000']); + if (reserved.code === 0) { + throw new Error(`expected nonzero exit for reserved name "ps"; got code=0, out=${reserved.combined.slice(0, 400)}`); + } + if (!/reserved/i.test(reserved.combined)) { + throw new Error(`expected "reserved" in error; got: ${reserved.combined.slice(0, 400)}`); + } + + const badchar = await runSubcommand(['config', 'save', 'foo!bar', '-l', '3000']); + if (badchar.code === 0) { + throw new Error(`expected nonzero exit for "foo!bar"; got code=0, out=${badchar.combined.slice(0, 400)}`); + } + if (!/alphanumeric|hyphens|underscores/i.test(badchar.combined)) { + throw new Error(`expected char-class error; got: ${badchar.combined.slice(0, 400)}`); + } + + const longName = 'a'.repeat(129); + const tooLong = await runSubcommand(['config', 'save', longName, '-l', '3000']); + if (tooLong.code === 0) { + throw new Error(`expected nonzero exit for 129-char name; got code=0`); + } + if (!/128|exceed/i.test(tooLong.combined)) { + throw new Error(`expected length error; got: ${tooLong.combined.slice(0, 400)}`); + } + + const dir = sandbox.tunnelsConfigDir(); + if (fs.existsSync(dir)) { + const files = fs.readdirSync(dir); + if (files.length > 0) { + throw new Error(`invalid names should not create config files; found: ${files.join(', ')}`); + } + } + }, +}; diff --git a/test/e2e/cases/config-save-full.cjs b/test/e2e/cases/config-save-full.cjs new file mode 100644 index 0000000..0398e80 --- /dev/null +++ b/test/e2e/cases/config-save-full.cjs @@ -0,0 +1,115 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + runSubcommand, +} = require('../lib/framework.cjs'); + +function eq(actual, expected, label) { + if (actual !== expected) { + throw new Error(`${label}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } +} + +function deepEq(actual, expected, label) { + const a = JSON.stringify(actual); + const b = JSON.stringify(expected); + if (a !== b) { + throw new Error(`${label}: expected ${b}, got ${a}`); + } +} + +module.exports = { + name: 'config-save-full', + async run() { + sandbox.reset(); + + const save = await runSubcommand([ + 'config', 'save', 'fullcfg', + '-R0:localhost:80', + '-Llocalhost:4300:localhost:4300', + '-o', 'ServerAliveInterval=30', + 'force+qr@free.pinggy.io', + 'k:asdfasdfasdf', + 'x:https', + 'x:localServerTls:localhost', + 'x:passpreflight', + 'x:noreverseproxy', + 'x:xff', + 'x:fullurl', + 'a:x-pinggy-org:x-pinggy', + 'u:user-agent:user-good', + 'r:x-postman', + ]); + if (save.code !== 0) { + throw new Error(`config save exit=${save.code}: ${save.combined.slice(0, 600)}`); + } + if (!/Config "fullcfg" saved/i.test(save.combined)) { + throw new Error(`expected save success message; got: ${save.combined.slice(0, 600)}`); + } + + const files = fs.readdirSync(sandbox.tunnelsConfigDir()); + const match = files.find((f) => f.startsWith('fullcfg_') && f.endsWith('.json')); + if (!match) { + throw new Error(`no fullcfg_*.json file found in ${sandbox.tunnelsConfigDir()}: ${files.join(', ')}`); + } + + const onDisk = JSON.parse(fs.readFileSync(path.join(sandbox.tunnelsConfigDir(), match), 'utf-8')); + + eq(onDisk.name, 'fullcfg', 'name'); + eq(onDisk.autoStart, false, 'autoStart'); + if (!onDisk.configId) throw new Error('configId missing from saved file'); + if (!onDisk.tunnelConfig) throw new Error('tunnelConfig missing'); + + const tc = onDisk.tunnelConfig; + + eq(tc.serverAddress, 'free.pinggy.io', 'tunnelConfig.serverAddress'); + eq(tc.token, '', 'tunnelConfig.token'); + eq(tc.force, true, 'tunnelConfig.force'); + eq(tc.isQRCode, true, 'tunnelConfig.isQRCode'); + eq(tc.webDebugger, 'localhost:4300', 'tunnelConfig.webDebugger'); + eq(tc.httpsOnly, true, 'tunnelConfig.httpsOnly'); + eq(tc.allowPreflight, true, 'tunnelConfig.allowPreflight'); + eq(tc.reverseProxy, false, 'tunnelConfig.reverseProxy'); + eq(tc.xForwardedFor, true, 'tunnelConfig.xForwardedFor'); + eq(tc.originalRequestUrl, true, 'tunnelConfig.originalRequestUrl'); + + deepEq(tc.bearerTokenAuth, ['asdfasdfasdf'], 'tunnelConfig.bearerTokenAuth'); + + if (!Array.isArray(tc.forwarding) || tc.forwarding.length !== 1) { + throw new Error(`forwarding shape wrong: ${JSON.stringify(tc.forwarding)}`); + } + deepEq( + tc.forwarding[0], + { address: 'https://localhost:80', type: 'http' }, + 'tunnelConfig.forwarding[0]' + ); + + if (!Array.isArray(tc.headerModification) || tc.headerModification.length !== 3) { + throw new Error(`headerModification shape wrong: ${JSON.stringify(tc.headerModification)}`); + } + deepEq( + tc.headerModification[0], + { type: 'add', key: 'x-pinggy-org', value: ['x-pinggy'] }, + 'tunnelConfig.headerModification[0] (add)' + ); + deepEq( + tc.headerModification[1], + { type: 'update', key: 'user-agent', value: ['user-good'] }, + 'tunnelConfig.headerModification[1] (update)' + ); + deepEq( + tc.headerModification[2], + { type: 'remove', key: 'x-postman', value: [] }, + 'tunnelConfig.headerModification[2] (remove)' + ); + + const show = await runSubcommand(['config', 'show', 'fullcfg']); + if (show.code !== 0) throw new Error(`config show exit=${show.code}: ${show.combined.slice(0, 600)}`); + for (const needle of ['fullcfg', 'free.pinggy.io', 'localhost:80', 'localhost:4300']) { + if (!show.combined.includes(needle)) { + throw new Error(`config show missing "${needle}": ${show.combined.slice(0, 800)}`); + } + } + }, +}; diff --git a/test/e2e/cases/config-save-list.cjs b/test/e2e/cases/config-save-list.cjs new file mode 100644 index 0000000..b23c61a --- /dev/null +++ b/test/e2e/cases/config-save-list.cjs @@ -0,0 +1,45 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + runSubcommand, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'config-save-list', + async run() { + sandbox.reset(); + + const save = await runSubcommand(['config', 'save', 'mycfg', '-l', '3000']); + if (save.code !== 0) { + throw new Error(`config save exit=${save.code}: ${save.combined.slice(0, 400)}`); + } + if (!/Config "mycfg" saved/i.test(save.combined)) { + throw new Error(`expected save success message; got: ${save.combined.slice(0, 400)}`); + } + + const files = fs.readdirSync(sandbox.tunnelsConfigDir()); + const match = files.find((f) => f.startsWith('mycfg_') && f.endsWith('.json')); + if (!match) { + throw new Error(`no mycfg_*.json file found in ${sandbox.tunnelsConfigDir()}: ${files.join(', ')}`); + } + + const onDisk = JSON.parse(fs.readFileSync(path.join(sandbox.tunnelsConfigDir(), match), 'utf-8')); + if (onDisk.name !== 'mycfg') throw new Error(`saved name mismatch: ${onDisk.name}`); + if (onDisk.autoStart !== false) throw new Error(`autoStart should default to false: ${onDisk.autoStart}`); + if (!onDisk.configId) throw new Error('configId missing from saved file'); + if (!onDisk.tunnelConfig) throw new Error('tunnelConfig missing'); + + const list = await runSubcommand(['config', 'list']); + if (list.code !== 0) throw new Error(`config list exit=${list.code}: ${list.combined.slice(0, 400)}`); + if (!/mycfg/.test(list.combined)) { + throw new Error(`config list missing mycfg: ${list.combined.slice(0, 400)}`); + } + + const show = await runSubcommand(['config', 'show', 'mycfg']); + if (show.code !== 0) throw new Error(`config show exit=${show.code}: ${show.combined.slice(0, 400)}`); + if (!/mycfg/.test(show.combined) || !/localhost:3000/.test(show.combined)) { + throw new Error(`config show missing fields: ${show.combined.slice(0, 600)}`); + } + }, +}; diff --git a/test/e2e/cases/config-update.cjs b/test/e2e/cases/config-update.cjs new file mode 100644 index 0000000..29c33aa --- /dev/null +++ b/test/e2e/cases/config-update.cjs @@ -0,0 +1,43 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + runSubcommand, + sleep, +} = require('../lib/framework.cjs'); + +function readConfig(name) { + const dir = sandbox.tunnelsConfigDir(); + const match = fs.readdirSync(dir).find((f) => f.startsWith(name + '_') && f.endsWith('.json')); + if (!match) return null; + return JSON.parse(fs.readFileSync(path.join(dir, match), 'utf-8')); +} + +module.exports = { + name: 'config-update', + async run() { + sandbox.reset(); + + const save = await runSubcommand(['config', 'save', 'updcfg', '-l', '3000']); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const before = readConfig('updcfg'); + if (!before) throw new Error('config not on disk after save'); + + await sleep(1100); + + const upd = await runSubcommand(['config', 'update', 'updcfg', '-l', '4000']); + if (upd.code !== 0) throw new Error(`update exit=${upd.code}: ${upd.combined.slice(0, 400)}`); + if (!/updated/i.test(upd.combined)) throw new Error(`expected "updated" message: ${upd.combined.slice(0, 400)}`); + + const after = readConfig('updcfg'); + if (!after) throw new Error('config missing after update'); + if (after.configId !== before.configId) throw new Error(`configId changed: ${before.configId} -> ${after.configId}`); + if (after.createdAt !== before.createdAt) throw new Error(`createdAt changed: ${before.createdAt} -> ${after.createdAt}`); + if (after.updatedAt === before.updatedAt) throw new Error(`updatedAt did not change`); + + const fwd = JSON.stringify(after.tunnelConfig.forwarding); + if (!/4000/.test(fwd)) throw new Error(`updated forwarding does not show port 4000: ${fwd}`); + if (/3000/.test(fwd)) throw new Error(`updated forwarding still references port 3000: ${fwd}`); + }, +}; diff --git a/test/e2e/cases/crash-recovery-detached.cjs b/test/e2e/cases/crash-recovery-detached.cjs new file mode 100644 index 0000000..5cc01b9 --- /dev/null +++ b/test/e2e/cases/crash-recovery-detached.cjs @@ -0,0 +1,77 @@ +const fs = require('fs'); +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + ipcRequest, + readDaemonState, + readDaemonInfo, + waitForDaemonInfo, + withEcho, + sleep, + isPidAlive, +} = require('../lib/framework.cjs'); + +async function getTunnelByName(name) { + const list = await ipcRequest('GET', '/tunnels'); + if (list.status !== 200 || !Array.isArray(list.json)) return null; + return list.json.find((t) => (t.tunnelconfig && t.tunnelconfig.name) === name) || null; +} + +module.exports = { + name: 'crash-recovery-detached', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + const info = await startDaemon(); + let cleanedUp = false; + try { + const save = await runSubcommand(['config', 'save', 'crashcfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const start = await runSubcommand(['start', 'crashcfg', '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start exit=${start.code}: ${start.combined.slice(0, 600)}`); + + const state = readDaemonState(); + if (!state || !Array.isArray(state.tunnels) || state.tunnels.length !== 1) { + throw new Error(`daemon-state.json malformed or wrong count: ${JSON.stringify(state)}`); + } + if (state.tunnels[0].mode !== 'detached') { + throw new Error(`tunnel mode should be detached: ${state.tunnels[0].mode}`); + } + if (state.tunnels[0].name !== 'crashcfg') { + throw new Error(`state name mismatch: ${state.tunnels[0].name}`); + } + + try { process.kill(info.pid, 'SIGKILL'); } catch {} + for (let i = 0; i < 50; i++) { + if (!isPidAlive(info.pid)) break; + await sleep(100); + } + if (isPidAlive(info.pid)) throw new Error(`daemon PID ${info.pid} still alive after SIGKILL`); + + if (!fs.existsSync(sandbox.daemonStatePath())) { + throw new Error(`daemon-state.json missing after SIGKILL — crash recovery cannot run`); + } + + await startDaemon(); + + let restored = null; + for (let i = 0; i < 60; i++) { + await sleep(500); + restored = await getTunnelByName('crashcfg'); + if (restored && restored.status && restored.status.state === 'running' && restored.remoteurls && restored.remoteurls.length) break; + } + if (!restored) throw new Error('tunnel "crashcfg" not restored after daemon restart'); + if (!restored.remoteurls || !restored.remoteurls.length) { + throw new Error(`restored tunnel has no URLs: ${JSON.stringify(restored.remoteurls)}`); + } + + await runSubcommand(['stop', 'crashcfg']); + } finally { + if (!cleanedUp) await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/daemon-stale-pid.cjs b/test/e2e/cases/daemon-stale-pid.cjs new file mode 100644 index 0000000..aa90e1d --- /dev/null +++ b/test/e2e/cases/daemon-stale-pid.cjs @@ -0,0 +1,42 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + runSubcommand, + stopDaemon, +} = require('../lib/framework.cjs'); + +function pickStalePid() { + for (const candidate of [999999, 888888, 777777]) { + try { process.kill(candidate, 0); } catch { return candidate; } + } + return 999999; +} + +module.exports = { + name: 'daemon-stale-pid', + async run() { + sandbox.reset(); + + const stalePid = pickStalePid(); + const dir = path.dirname(sandbox.daemonJsonPath()); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync( + sandbox.daemonJsonPath(), + JSON.stringify({ pid: stalePid, port: 1, startedAt: new Date().toISOString() }), + 'utf-8' + ); + + try { + const res = await runSubcommand(['daemon', 'status']); + if (!/No daemon is running/i.test(res.combined)) { + throw new Error(`expected stale daemon.json to be cleaned and reported absent; got: ${res.combined.slice(0, 400)}`); + } + if (fs.existsSync(sandbox.daemonJsonPath())) { + throw new Error('stale daemon.json was not removed after daemon status'); + } + } finally { + await stopDaemon(); + } + }, +}; diff --git a/test/e2e/cases/daemon-start-stop.cjs b/test/e2e/cases/daemon-start-stop.cjs new file mode 100644 index 0000000..4913741 --- /dev/null +++ b/test/e2e/cases/daemon-start-stop.cjs @@ -0,0 +1,55 @@ +const fs = require('fs'); +const { + sandbox, + startDaemon, + stopDaemon, + readDaemonInfo, + ipcRequest, + isPidAlive, + sleep, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'daemon-start-stop', + async run() { + sandbox.reset(); + + const info = await startDaemon(); + if (!info || !info.pid || !info.port) { + throw new Error(`daemon start did not return valid info: ${JSON.stringify(info)}`); + } + + const onDisk = readDaemonInfo(); + if (!onDisk || onDisk.pid !== info.pid || onDisk.port !== info.port) { + throw new Error(`daemon.json content mismatch: ${JSON.stringify(onDisk)} vs ${JSON.stringify(info)}`); + } + + if (!isPidAlive(info.pid)) { + throw new Error(`daemon PID ${info.pid} is not alive after start`); + } + + const ping = await ipcRequest('GET', '/ping'); + if (ping.status !== 200 || !ping.json || ping.json.pid !== info.pid) { + throw new Error(`ping mismatch: status=${ping.status} body=${ping.text.slice(0, 200)}`); + } + + const stop = await stopDaemon(); + if (!stop.ok) { + throw new Error(`daemon stop did not succeed: ${JSON.stringify(stop)}`); + } + + if (fs.existsSync(sandbox.daemonJsonPath())) { + throw new Error(`daemon.json still exists after stop`); + } + + let stillAlive = false; + for (let i = 0; i < 30; i++) { + if (!isPidAlive(info.pid)) break; + await sleep(100); + if (i === 29) stillAlive = true; + } + if (stillAlive) { + throw new Error(`daemon PID ${info.pid} still alive 3s after stop`); + } + }, +}; diff --git a/test/e2e/cases/daemon-status.cjs b/test/e2e/cases/daemon-status.cjs new file mode 100644 index 0000000..b1e2a8e --- /dev/null +++ b/test/e2e/cases/daemon-status.cjs @@ -0,0 +1,49 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + sleep, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'daemon-status', + async run() { + sandbox.reset(); + + const before = await runSubcommand(['daemon', 'status']); + if (!/No daemon is running/i.test(before.combined)) { + throw new Error(`status (no daemon) unexpected output: ${before.combined.slice(0, 400)}`); + } + + const info = await startDaemon(); + try { + const s1 = await runSubcommand(['daemon', 'status']); + if (s1.code !== 0) throw new Error(`status exit=${s1.code}: ${s1.combined.slice(0, 400)}`); + const pidMatch = s1.combined.match(/PID:\s*(\d+)/); + const portMatch = s1.combined.match(/Port:\s*(\d+)/); + const upMatch = s1.combined.match(/Uptime:\s*([^\n]+)/); + if (!pidMatch || !portMatch || !upMatch) { + throw new Error(`status output missing fields: ${s1.combined.slice(0, 500)}`); + } + if (parseInt(pidMatch[1], 10) !== info.pid) { + throw new Error(`status pid ${pidMatch[1]} != daemon pid ${info.pid}`); + } + if (parseInt(portMatch[1], 10) !== info.port) { + throw new Error(`status port ${portMatch[1]} != daemon port ${info.port}`); + } + + await sleep(2000); + const s2 = await runSubcommand(['daemon', 'status']); + const up1 = upMatch[1].trim(); + const up2Match = s2.combined.match(/Uptime:\s*([^\n]+)/); + if (!up2Match) throw new Error(`second status missing uptime: ${s2.combined.slice(0, 400)}`); + const up2 = up2Match[1].trim(); + if (up1 === up2) { + throw new Error(`uptime did not change between calls: '${up1}' vs '${up2}'`); + } + } finally { + await stopDaemon(); + } + }, +}; diff --git a/test/e2e/cases/detached-survives-cli-exit.cjs b/test/e2e/cases/detached-survives-cli-exit.cjs new file mode 100644 index 0000000..9206380 --- /dev/null +++ b/test/e2e/cases/detached-survives-cli-exit.cjs @@ -0,0 +1,84 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + ipcRequest, + readDaemonState, + withEcho, + fetchJson, + sleep, +} = require('../lib/framework.cjs'); + +const GRACE_MS = 5000; + +function extractHttpsUrl(text) { + const m = text.match(/https:\/\/\S+/); + return m ? m[0] : null; +} + +module.exports = { + name: 'detached-survives-cli-exit', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + try { + const save = await runSubcommand(['config', 'save', 'dtcfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const start = await runSubcommand(['start', 'dtcfg', '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start -b exit=${start.code}: ${start.combined.slice(0, 600)}`); + + const url = extractHttpsUrl(start.combined); + if (!url) throw new Error(`no https URL in start output: ${start.combined.slice(0, 600)}`); + + const state = readDaemonState(); + const tracked = state && Array.isArray(state.tunnels) + ? state.tunnels.find((t) => t.name === 'dtcfg') + : null; + if (!tracked) { + throw new Error(`detached tunnel missing from daemon-state.json: ${JSON.stringify(state)}`); + } + if (tracked.mode !== 'detached') { + throw new Error(`expected daemon-state mode=detached; got ${tracked.mode}`); + } + + const before = await ipcRequest('GET', '/tunnels'); + const beforeEntry = Array.isArray(before.json) + ? before.json.find((t) => t.tunnelconfig && t.tunnelconfig.name === 'dtcfg') + : null; + if (!beforeEntry) throw new Error(`/tunnels did not list dtcfg after start`); + if (beforeEntry.mode !== 'detached') { + throw new Error(`expected /tunnels mode=detached; got ${JSON.stringify(beforeEntry.mode)}`); + } + + await sleep(GRACE_MS + 3000); + + const after = await ipcRequest('GET', '/tunnels'); + const afterEntry = Array.isArray(after.json) + ? after.json.find((t) => t.tunnelconfig && t.tunnelconfig.name === 'dtcfg') + : null; + if (!afterEntry) { + throw new Error(`detached tunnel disappeared during grace window: ${JSON.stringify(after.json)}`); + } + if (!afterEntry.status || afterEntry.status.state !== 'running') { + throw new Error(`detached tunnel no longer running: ${JSON.stringify(afterEntry.status)}`); + } + + const res = await fetchJson(url); + if (res.status !== 200) { + throw new Error(`fetch through detached tunnel returned ${res.status}: ${res.text.slice(0, 300)}`); + } + if (!res.json || res.json.method !== 'GET') { + throw new Error(`unexpected echo body: ${res.text.slice(0, 300)}`); + } + + const stop = await runSubcommand(['stop', 'dtcfg']); + if (stop.code !== 0) throw new Error(`stop exit=${stop.code}: ${stop.combined.slice(0, 400)}`); + } finally { + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/foreground-grace-stops.cjs b/test/e2e/cases/foreground-grace-stops.cjs new file mode 100644 index 0000000..832baa6 --- /dev/null +++ b/test/e2e/cases/foreground-grace-stops.cjs @@ -0,0 +1,86 @@ +const fs = require('fs'); +const path = require('path'); +const { + sandbox, + workDir, + runSubcommand, + startDaemon, + stopDaemon, + ipcRequest, + readDaemonState, + withEcho, + spawnCli, + forceKill, + dumpLogs, + getBinary, + waitForTunnelByName, + waitForTunnelStopped, + sleep, +} = require('../lib/framework.cjs'); + +const GRACE_MS = 5000; + +module.exports = { + name: 'foreground-grace-stops', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + let proc = null; + const logFile = path.join(workDir, 'tunnel-fgcfg.log'); + try { + const save = await runSubcommand(['config', 'save', 'fgcfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + proc = spawnCli(getBinary(), ['start', 'fgcfg', '--noTui'], { logFile }); + + const tunnel = await waitForTunnelByName('fgcfg', { maxMs: 60000 }); + if (!tunnel) { + dumpLogs(logFile); + throw new Error('foreground tunnel "fgcfg" never reached running state'); + } + if (tunnel.mode !== 'foreground') { + throw new Error(`expected mode=foreground; got ${JSON.stringify(tunnel.mode)}`); + } + + const state = readDaemonState(); + const tracked = state && Array.isArray(state.tunnels) + ? state.tunnels.find((t) => t.name === 'fgcfg') + : null; + if (tracked) { + throw new Error(`foreground tunnel must not be in daemon-state.json: ${JSON.stringify(tracked)}`); + } + + forceKill(proc); + proc = null; + + const result = await waitForTunnelStopped('fgcfg', { maxMs: GRACE_MS + 7000 }); + if (!result) { + dumpLogs(logFile); + const list = await ipcRequest('GET', '/tunnels'); + throw new Error(`tunnel "fgcfg" still running after grace window: ${JSON.stringify(list.json)}`); + } + + const ps = await runSubcommand(['ps']); + const fgRow = ps.combined.split('\n').find((l) => /fgcfg/.test(l)); + if (fgRow && /running/.test(fgRow)) { + throw new Error(`ps still shows fgcfg running: ${fgRow}`); + } + + if (fs.existsSync(sandbox.daemonStatePath())) { + const after = readDaemonState(); + const stillThere = after && Array.isArray(after.tunnels) + ? after.tunnels.find((t) => t.name === 'fgcfg') + : null; + if (stillThere) { + throw new Error(`daemon-state.json still tracks fgcfg after auto-stop: ${JSON.stringify(stillThere)}`); + } + } + } finally { + if (proc) forceKill(proc); + await sleep(200); + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/headers.cjs b/test/e2e/cases/headers.cjs index a2598e0..5466257 100644 --- a/test/e2e/cases/headers.cjs +++ b/test/e2e/cases/headers.cjs @@ -1,4 +1,3 @@ -const fs = require('fs'); const { withTunnel, withEcho, pickHttpsUrl, fetchJson } = require('../lib/framework.cjs'); module.exports = { @@ -18,7 +17,7 @@ module.exports = { ], }, }, - async ({ urls, log }) => { + async ({ urls }) => { const url = pickHttpsUrl(urls); const { status, json } = await fetchJson(url, { headers: { @@ -35,11 +34,8 @@ module.exports = { if (h['user-agent'] !== 'e2e-runner') throw new Error(`user-agent not updated: ${h['user-agent']}. ${hDump()}`); if (h['cookie']) throw new Error(`cookie should have been removed, got: ${h['cookie']}. ${hDump()}`); if (h['x-keep-me'] !== 'still-here') throw new Error(`passthrough header lost. ${hDump()}`); - // x:xff: free-tier Pinggy may not inject the header on the wire, but CLI must - // pass the option to the SDK. Verify via worker log. - const logContent = fs.readFileSync(log, 'utf-8'); - if (!/X-Forwarded-For configuration set to:\s*true/i.test(logContent)) { - throw new Error('CLI did not propagate x:xff to the SDK'); + if (!h['x-forwarded-for']) { + throw new Error(`x-forwarded-for not injected by tunnel edge despite x:xff. ${hDump()}`); } } ) diff --git a/test/e2e/cases/ipc-http.cjs b/test/e2e/cases/ipc-http.cjs new file mode 100644 index 0000000..2ed4d37 --- /dev/null +++ b/test/e2e/cases/ipc-http.cjs @@ -0,0 +1,61 @@ +const { + sandbox, + startDaemon, + stopDaemon, + ipcRequest, + readDaemonInfo, + waitForDaemonGone, + isPidAlive, + sleep, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'ipc-http', + async run() { + sandbox.reset(); + const info = await startDaemon(); + let manuallyStopped = false; + try { + const ping = await ipcRequest('GET', '/ping'); + if (ping.status !== 200) throw new Error(`/ping status=${ping.status}`); + if (!ping.json || ping.json.status !== 'ok' || typeof ping.json.uptime !== 'number' || ping.json.pid !== info.pid) { + throw new Error(`/ping body unexpected: ${ping.text.slice(0, 300)}`); + } + + const list = await ipcRequest('GET', '/tunnels'); + if (list.status !== 200) throw new Error(`/tunnels status=${list.status}`); + if (!Array.isArray(list.json) || list.json.length !== 0) { + throw new Error(`/tunnels should be empty array: ${list.text.slice(0, 300)}`); + } + + const tunnelLogging = await ipcRequest('GET', '/config/tunnel-logging'); + if (tunnelLogging.status !== 200 || typeof tunnelLogging.json.enabled !== 'boolean') { + throw new Error(`/config/tunnel-logging unexpected: ${tunnelLogging.text.slice(0, 300)}`); + } + + const logPaths = await ipcRequest('GET', '/logs/paths'); + if (logPaths.status !== 200 || typeof logPaths.json.daemon !== 'string') { + throw new Error(`/logs/paths unexpected: ${logPaths.text.slice(0, 300)}`); + } + + const shutdown = await ipcRequest('POST', '/shutdown', {}); + if (shutdown.status !== 200) throw new Error(`/shutdown status=${shutdown.status}`); + if (!shutdown.json || shutdown.json.status !== 'shutting_down') { + throw new Error(`/shutdown body unexpected: ${shutdown.text.slice(0, 300)}`); + } + manuallyStopped = true; + + const exited = await waitForDaemonGone(8000); + if (!exited) { + throw new Error(`daemon did not exit after /shutdown (PID ${info.pid} alive=${isPidAlive(info.pid)})`); + } + + if (readDaemonInfo() !== null) { + throw new Error(`daemon.json still present after /shutdown`); + } + } finally { + if (!manuallyStopped) await stopDaemon(); + await sleep(200); + } + }, +}; diff --git a/test/e2e/cases/ipc-loglevel.cjs b/test/e2e/cases/ipc-loglevel.cjs new file mode 100644 index 0000000..43c81b5 --- /dev/null +++ b/test/e2e/cases/ipc-loglevel.cjs @@ -0,0 +1,55 @@ +const { + sandbox, + startDaemon, + stopDaemon, + ipcRequest, + readDaemonConfig, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'ipc-loglevel', + async run() { + sandbox.reset(); + + await startDaemon(); + let okToStop = true; + try { + const initial = await ipcRequest('GET', '/loglevel'); + if (initial.status !== 200 || !['debug', 'info', 'error'].includes(initial.json.level)) { + throw new Error(`/loglevel initial: ${initial.text.slice(0, 300)}`); + } + + const set = await ipcRequest('POST', '/loglevel', { level: 'debug' }); + if (set.status !== 200 || set.json.level !== 'debug') { + throw new Error(`POST /loglevel debug: status=${set.status} body=${set.text.slice(0, 300)}`); + } + + const after = await ipcRequest('GET', '/loglevel'); + if (after.json.level !== 'debug') { + throw new Error(`level not persisted in memory: ${after.text.slice(0, 300)}`); + } + + const cfg = readDaemonConfig(); + if (!cfg || cfg.logLevel !== 'debug') { + throw new Error(`daemon-config.json missing logLevel=debug; got ${JSON.stringify(cfg)}`); + } + + await stopDaemon(); + okToStop = false; + + await startDaemon(); + okToStop = true; + const restored = await ipcRequest('GET', '/loglevel'); + if (restored.json.level !== 'debug') { + throw new Error(`log level not restored after restart: ${restored.text.slice(0, 300)}`); + } + + const reset = await ipcRequest('POST', '/loglevel', { level: 'info' }); + if (reset.json.level !== 'info') { + throw new Error(`reset to info failed: ${reset.text.slice(0, 300)}`); + } + } finally { + if (okToStop) await stopDaemon(); + } + }, +}; diff --git a/test/e2e/cases/ps-output.cjs b/test/e2e/cases/ps-output.cjs new file mode 100644 index 0000000..5bf2683 --- /dev/null +++ b/test/e2e/cases/ps-output.cjs @@ -0,0 +1,52 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + withEcho, + sleep, +} = require('../lib/framework.cjs'); + +module.exports = { + name: 'ps-output', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + try { + const emptyPs = await runSubcommand(['ps']); + if (!/No tunnels running/i.test(emptyPs.combined)) { + throw new Error(`empty ps unexpected output: ${emptyPs.combined.slice(0, 400)}`); + } + + for (const name of ['ps-a', 'ps-b']) { + const save = await runSubcommand(['config', 'save', name, '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save ${name} failed: ${save.combined.slice(0, 400)}`); + const start = await runSubcommand(['start', name, '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start ${name} exit=${start.code}: ${start.combined.slice(0, 600)}`); + } + + await sleep(1500); + const ps = await runSubcommand(['ps']); + if (ps.code !== 0) throw new Error(`ps exit=${ps.code}: ${ps.combined.slice(0, 400)}`); + + const lines = ps.combined.split('\n'); + const dataLines = lines.filter((l) => /^[a-f0-9]{8,}/.test(l.trim())); + const hasA = dataLines.some((l) => /ps-a/.test(l)); + const hasB = dataLines.some((l) => /ps-b/.test(l)); + if (!hasA || !hasB) { + throw new Error(`expected both ps-a and ps-b in ps output: ${ps.combined.slice(0, 800)}`); + } + + const running = dataLines.filter((l) => /running/.test(l)); + if (running.length < 2) { + throw new Error(`expected at least 2 running rows; got ${running.length}: ${ps.combined.slice(0, 800)}`); + } + + await runSubcommand(['stop', 'ps-a', 'ps-b']); + } finally { + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/restart.cjs b/test/e2e/cases/restart.cjs new file mode 100644 index 0000000..eb4a151 --- /dev/null +++ b/test/e2e/cases/restart.cjs @@ -0,0 +1,72 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + ipcRequest, + withEcho, + fetchJson, + sleep, +} = require('../lib/framework.cjs'); + +async function getTunnelByName(name) { + const list = await ipcRequest('GET', '/tunnels'); + if (list.status !== 200 || !Array.isArray(list.json)) { + throw new Error(`/tunnels failed: status=${list.status}`); + } + return list.json.find((t) => (t.tunnelconfig && t.tunnelconfig.name) === name) || null; +} + +function extractHttpsUrl(text) { + const m = text.match(/https:\/\/\S+/); + return m ? m[0] : null; +} + +module.exports = { + name: 'restart', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + try { + const save = await runSubcommand(['config', 'save', 'rstcfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const start = await runSubcommand(['start', 'rstcfg', '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start exit=${start.code}: ${start.combined.slice(0, 600)}`); + + const before = await getTunnelByName('rstcfg'); + if (!before) throw new Error(`tunnel "rstcfg" not in /tunnels after start`); + const configIdBefore = before.tunnelconfig.configId; + + const restart = await runSubcommand(['restart', 'rstcfg'], { timeoutMs: 60000 }); + if (restart.code !== 0) throw new Error(`restart exit=${restart.code}: ${restart.combined.slice(0, 400)}`); + if (!/restarting/i.test(restart.combined)) { + throw new Error(`expected restarting message: ${restart.combined.slice(0, 400)}`); + } + + let after = null; + for (let i = 0; i < 60; i++) { + await sleep(500); + after = await getTunnelByName('rstcfg'); + if (after && after.status && after.status.state === 'running' && after.remoteurls && after.remoteurls.length) break; + } + if (!after) throw new Error('tunnel disappeared after restart'); + if (after.tunnelconfig.configId !== configIdBefore) { + throw new Error(`configId changed: ${configIdBefore} -> ${after.tunnelconfig.configId}`); + } + + const url = after.remoteurls.find((u) => u.startsWith('https://')); + if (!url) throw new Error(`no https URL after restart: ${JSON.stringify(after.remoteurls)}`); + const res = await fetchJson(url); + if (res.status !== 200) { + throw new Error(`fetch after restart status=${res.status}: ${res.text.slice(0, 300)}`); + } + + await runSubcommand(['stop', 'rstcfg']); + } finally { + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/start-background.cjs b/test/e2e/cases/start-background.cjs new file mode 100644 index 0000000..d776ecf --- /dev/null +++ b/test/e2e/cases/start-background.cjs @@ -0,0 +1,66 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + withEcho, + fetchJson, + sleep, +} = require('../lib/framework.cjs'); + +function extractHttpsUrl(text) { + const m = text.match(/https:\/\/\S+/); + return m ? m[0] : null; +} + +module.exports = { + name: 'start-background', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + const daemonInfo = await startDaemon(); + try { + const save = await runSubcommand(['config', 'save', 'bgcfg', '-l', String(echo.port)]); + if (save.code !== 0) throw new Error(`save failed: ${save.combined.slice(0, 400)}`); + + const start = await runSubcommand(['start', 'bgcfg', '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start -b exit=${start.code}: ${start.combined.slice(0, 600)}`); + if (!/Success.*bgcfg.*started|started.*ID/i.test(start.combined)) { + throw new Error(`expected start success message: ${start.combined.slice(0, 600)}`); + } + + const url = extractHttpsUrl(start.combined); + if (!url) throw new Error(`no https URL in start output: ${start.combined.slice(0, 600)}`); + + await sleep(2000); + const res = await fetchJson(url); + if (res.status !== 200) { + throw new Error(`fetch through tunnel returned ${res.status}: ${res.text.slice(0, 300)}`); + } + if (!res.json || res.json.method !== 'GET') { + throw new Error(`unexpected echo body: ${res.text.slice(0, 300)}`); + } + + const ps = await runSubcommand(['ps']); + if (!/bgcfg/.test(ps.combined)) { + throw new Error(`ps did not list bgcfg: ${ps.combined.slice(0, 400)}`); + } + + const stop = await runSubcommand(['stop', 'bgcfg']); + if (stop.code !== 0) throw new Error(`stop exit=${stop.code}: ${stop.combined.slice(0, 400)}`); + if (!/stopped/i.test(stop.combined)) { + throw new Error(`expected stop confirmation: ${stop.combined.slice(0, 400)}`); + } + + await sleep(500); + const ps2 = await runSubcommand(['ps']); + const bgRow = ps2.combined.split('\n').find((l) => /bgcfg/.test(l)); + if (bgRow && /running/.test(bgRow)) { + throw new Error(`bgcfg still "running" after stop: ${bgRow}`); + } + } finally { + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/stop-resolution.cjs b/test/e2e/cases/stop-resolution.cjs new file mode 100644 index 0000000..a680179 --- /dev/null +++ b/test/e2e/cases/stop-resolution.cjs @@ -0,0 +1,60 @@ +const { + sandbox, + runSubcommand, + startDaemon, + stopDaemon, + ipcRequest, + withEcho, + sleep, +} = require('../lib/framework.cjs'); + +async function startNamed(name, port) { + const save = await runSubcommand(['config', 'save', name, '-l', String(port)]); + if (save.code !== 0) throw new Error(`save ${name} failed: ${save.combined.slice(0, 400)}`); + const start = await runSubcommand(['start', name, '-b'], { timeoutMs: 60000 }); + if (start.code !== 0) throw new Error(`start ${name} exit=${start.code}: ${start.combined.slice(0, 600)}`); +} + +async function tunnelIdForName(name) { + const list = await ipcRequest('GET', '/tunnels'); + if (list.status !== 200 || !Array.isArray(list.json)) { + throw new Error(`/tunnels failed: status=${list.status}`); + } + const match = list.json.find((t) => (t.tunnelconfig && t.tunnelconfig.name) === name); + if (!match) throw new Error(`no tunnel matching name ${name} in ipc list`); + return match.tunnelid; +} + +module.exports = { + name: 'stop-resolution', + async run() { + sandbox.reset(); + await withEcho('http', async (echo) => { + await startDaemon(); + try { + await startNamed('stp-name', echo.port); + const stopByName = await runSubcommand(['stop', 'stp-name']); + if (stopByName.code !== 0) throw new Error(`stop by name exit=${stopByName.code}: ${stopByName.combined.slice(0, 400)}`); + if (!/stopped/i.test(stopByName.combined)) throw new Error(`expected stopped message: ${stopByName.combined.slice(0, 400)}`); + + await runSubcommand(['config', 'delete', 'stp-name']); + await startNamed('stp-id', echo.port); + const tunnelId = await tunnelIdForName('stp-id'); + const prefix = tunnelId.slice(0, 8); + + const stopByPrefix = await runSubcommand(['stop', prefix]); + if (stopByPrefix.code !== 0) throw new Error(`stop by id-prefix exit=${stopByPrefix.code}: ${stopByPrefix.combined.slice(0, 400)}`); + if (!/stopped/i.test(stopByPrefix.combined)) throw new Error(`expected stopped message: ${stopByPrefix.combined.slice(0, 400)}`); + + const stopMissing = await runSubcommand(['stop', 'nonexistent-zzz']); + if (!/No tunnel found/i.test(stopMissing.combined)) { + throw new Error(`expected "No tunnel found" message: ${stopMissing.combined.slice(0, 400)}`); + } + + await sleep(500); + } finally { + await stopDaemon(); + } + }); + }, +}; diff --git a/test/e2e/cases/udp.cjs b/test/e2e/cases/udp.cjs index a97147e..1ee20b4 100644 --- a/test/e2e/cases/udp.cjs +++ b/test/e2e/cases/udp.cjs @@ -1,5 +1,5 @@ const dgram = require('dgram'); -const { withTunnel, withEcho, pickProtoUrl, SkipCase } = require('../lib/framework.cjs'); +const { withTunnel, withEcho, pickProtoUrl, SkipCase, sleep } = require('../lib/framework.cjs'); function udpEcho(host, port, payload) { return new Promise((resolve, reject) => { @@ -21,20 +21,40 @@ function udpEcho(host, port, payload) { }); } +async function runOnce(echo) { + await withTunnel( + { name: 'udp', build: { type: 'udp', localPort: echo.port } }, + async ({ urls }) => { + const udpUrl = pickProtoUrl(urls, 'udp'); + if (!udpUrl) throw new SkipCase(`no udp:// url; got ${urls.join(',')}`); + const u = new URL(udpUrl); + if (!u.port) throw new SkipCase(`udp url has no port: ${udpUrl}`); + await udpEcho(u.hostname, parseInt(u.port, 10), Buffer.from('hello-udp-e2e')); + } + ); +} + module.exports = { name: 'udp', async run() { - await withEcho('udp', (echo) => - withTunnel( - { name: 'udp', build: { type: 'udp', localPort: echo.port } }, - async ({ urls }) => { - const udpUrl = pickProtoUrl(urls, 'udp'); - if (!udpUrl) throw new SkipCase(`no udp:// url; got ${urls.join(',')}`); - const u = new URL(udpUrl); - if (!u.port) throw new SkipCase(`udp url has no port: ${udpUrl}`); - await udpEcho(u.hostname, parseInt(u.port, 10), Buffer.from('hello-udp-e2e')); + await withEcho('udp', async (echo) => { + const MAX_ATTEMPTS = 3; + let lastErr; + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + try { + await runOnce(echo); + if (attempt > 1) { + process.stdout.write(` udp passed on attempt ${attempt}/${MAX_ATTEMPTS}\n`); + } + return; + } catch (e) { + if (e && e.skip) throw e; + lastErr = e; + process.stdout.write(` udp attempt ${attempt}/${MAX_ATTEMPTS} failed: ${e && e.message ? e.message : e}\n`); + if (attempt < MAX_ATTEMPTS) await sleep(2000); } - ) - ); + } + throw new Error(`udp failed all ${MAX_ATTEMPTS} attempts; last: ${lastErr && lastErr.message ? lastErr.message : lastErr}`); + }); }, }; diff --git a/test/e2e/lib/cli.cjs b/test/e2e/lib/cli.cjs index b26a55d..175c1e0 100644 --- a/test/e2e/lib/cli.cjs +++ b/test/e2e/lib/cli.cjs @@ -5,6 +5,12 @@ const os = require('os'); const isWindows = process.platform === 'win32'; +let extraEnv = null; +function setExtraEnv(envObj) { extraEnv = envObj; } +function buildEnv(overrides) { + return { ...process.env, ...(extraEnv || {}), ...(overrides || {}) }; +} + function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } @@ -99,12 +105,24 @@ function killProc(proc) { } } -function spawnCli(binary, args, { logFile, cwd } = {}) { +// Bypasses the CLI's shutdown handlers (no chance to call client.handleStop) +// so the daemon sees an uncleanly-closed WS subscription +function forceKill(proc) { + if (!proc || proc.killed || proc.exitCode !== null) return; + if (isWindows) { + try { execSync(`taskkill /pid ${proc.pid} /T /F`, { stdio: 'ignore' }); } catch {} + } else { + try { proc.kill('SIGKILL'); } catch {} + } +} + +function spawnCli(binary, args, { logFile, cwd, env } = {}) { const out = logFile ? fs.openSync(logFile, 'w') : 'ignore'; const err = logFile ? fs.openSync(logFile + '.err', 'w') : 'ignore'; const proc = spawn(binary, args, { stdio: ['ignore', out, err], cwd: cwd || process.cwd(), + env: buildEnv(env), windowsHide: true, }); return proc; @@ -157,8 +175,11 @@ module.exports = { fetchUrls, waitForTunnel, killProc, + forceKill, spawnCli, dumpLogs, startEchoServer, stopEchoServer, + setExtraEnv, + buildEnv, }; diff --git a/test/e2e/lib/daemon.cjs b/test/e2e/lib/daemon.cjs new file mode 100644 index 0000000..b5cb9cc --- /dev/null +++ b/test/e2e/lib/daemon.cjs @@ -0,0 +1,181 @@ +const fs = require('fs'); +const { spawn } = require('child_process'); +const { sleep, isWindows, buildEnv } = require('./cli.cjs'); + +function stripAnsi(s) { + return s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '').replace(/\x1b\][^\x07]*\x07/g, ''); +} + +async function runSubcommand(binary, args, { timeoutMs = 20000, input = null, env } = {}) { + return new Promise((resolve) => { + const proc = spawn(binary, args, { + stdio: ['pipe', 'pipe', 'pipe'], + env: buildEnv(env), + windowsHide: true, + }); + let stdout = ''; + let stderr = ''; + let timedOut = false; + const timer = setTimeout(() => { + timedOut = true; + try { + if (isWindows) { + spawn('taskkill', ['/pid', String(proc.pid), '/T', '/F']); + } else { + proc.kill('SIGKILL'); + } + } catch {} + }, timeoutMs); + proc.stdout.on('data', (c) => { stdout += c.toString('utf-8'); }); + proc.stderr.on('data', (c) => { stderr += c.toString('utf-8'); }); + if (input != null) { + proc.stdin.end(input); + } else { + proc.stdin.end(); + } + proc.on('exit', (code, signal) => { + clearTimeout(timer); + resolve({ + code, + signal, + timedOut, + stdout: stripAnsi(stdout), + stderr: stripAnsi(stderr), + combined: stripAnsi(stdout + stderr), + }); + }); + proc.on('error', (err) => { + clearTimeout(timer); + resolve({ + code: -1, + signal: null, + timedOut, + stdout: stripAnsi(stdout), + stderr: stripAnsi(stderr + '\n' + err.message), + combined: stripAnsi(stdout + stderr + '\n' + err.message), + }); + }); + }); +} + +function readDaemonInfo(sandbox) { + try { + const raw = fs.readFileSync(sandbox.daemonJsonPath(), 'utf-8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +function readDaemonState(sandbox) { + try { + const raw = fs.readFileSync(sandbox.daemonStatePath(), 'utf-8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +function readDaemonConfig(sandbox) { + try { + const raw = fs.readFileSync(sandbox.daemonConfigPath(), 'utf-8'); + return JSON.parse(raw); + } catch { + return null; + } +} + +function isPidAlive(pid) { + try { process.kill(pid, 0); return true; } catch { return false; } +} + +async function waitForDaemonInfo(sandbox, maxMs = 10000) { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + const info = readDaemonInfo(sandbox); + if (info && info.pid && info.port && isPidAlive(info.pid)) return info; + await sleep(100); + } + return null; +} + +async function waitForDaemonGone(sandbox, maxMs = 10000) { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + const info = readDaemonInfo(sandbox); + if (!info || !isPidAlive(info.pid)) return true; + await sleep(100); + } + return false; +} + +async function startDaemon(binary, sandbox) { + const res = await runSubcommand(binary, ['daemon', 'start'], { timeoutMs: 15000 }); + if (res.code !== 0) { + throw new Error(`daemon start exited ${res.code}: ${res.combined}`); + } + const info = await waitForDaemonInfo(sandbox, 8000); + if (!info) { + throw new Error(`daemon.json did not appear after \`daemon start\`. stdout: ${res.stdout}`); + } + return info; +} + +async function stopDaemon(binary, sandbox, { force = true } = {}) { + const info = readDaemonInfo(sandbox); + if (!info) return { ok: true, alreadyGone: true }; + + const res = await runSubcommand(binary, ['daemon', 'stop'], { timeoutMs: 10000 }); + const exited = await waitForDaemonGone(sandbox, 8000); + if (exited) return { ok: true, alreadyGone: false, out: res }; + + if (force && isPidAlive(info.pid)) { + try { process.kill(info.pid, 'SIGKILL'); } catch {} + try { fs.unlinkSync(sandbox.daemonJsonPath()); } catch {} + } + return { ok: false, alreadyGone: false, out: res }; +} + +async function ipcRequest(sandbox, method, route, body) { + const info = readDaemonInfo(sandbox); + if (!info) throw new Error('IPC request requires running daemon'); + const opts = { + method, + headers: { 'X-Pinggy-Origin': 'app' }, + signal: AbortSignal.timeout(15000), + }; + if (body !== undefined) { + opts.headers['Content-Type'] = 'application/json'; + opts.body = JSON.stringify(body); + } + const res = await fetch(`http://127.0.0.1:${info.port}${route}`, opts); + const text = await res.text(); + let json = null; + try { json = JSON.parse(text); } catch {} + return { status: res.status, text, json }; +} + +async function withDaemon(binary, sandbox, fn) { + sandbox.reset(); + const info = await startDaemon(binary, sandbox); + try { + return await fn(info); + } finally { + await stopDaemon(binary, sandbox); + } +} + +module.exports = { + stripAnsi, + runSubcommand, + readDaemonInfo, + readDaemonState, + readDaemonConfig, + waitForDaemonInfo, + waitForDaemonGone, + startDaemon, + stopDaemon, + ipcRequest, + withDaemon, + isPidAlive, +}; diff --git a/test/e2e/lib/framework.cjs b/test/e2e/lib/framework.cjs index 9d6ecc9..c2c6edc 100644 --- a/test/e2e/lib/framework.cjs +++ b/test/e2e/lib/framework.cjs @@ -5,15 +5,22 @@ const { sleep, waitForTunnel, killProc, + forceKill, spawnCli, dumpLogs, startEchoServer, stopEchoServer, + setExtraEnv, } = require('./cli.cjs'); +const { createSandbox } = require('./sandbox.cjs'); +const daemon = require('./daemon.cjs'); const SERVER = 'free.pinggy.io'; const workDir = fs.mkdtempSync(path.join(os.tmpdir(), 'pinggy-e2e-')); +const sandbox = createSandbox(workDir); +setExtraEnv(sandbox.env); + let dbgPortCounter = 4300; function nextDbgPort() { return dbgPortCounter++; } @@ -121,6 +128,44 @@ function pickProtoUrl(urls, proto) { return urls.find((u) => u.startsWith(proto + '://')); } +async function waitForTunnelByName(name, { maxMs = 60000, pollMs = 500 } = {}) { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + try { + const list = await daemon.ipcRequest(sandbox, 'GET', '/tunnels'); + if (list.status === 200 && Array.isArray(list.json)) { + const t = list.json.find((x) => x.tunnelconfig && x.tunnelconfig.name === name); + if (t && t.status && t.status.state === 'running' && Array.isArray(t.remoteurls) && t.remoteurls.length) { + return t; + } + } + } catch {} + await sleep(pollMs); + } + return null; +} + +// The daemon keeps stopped tunnels in the manager (state Closed/Exited) so +// `/tunnels` still lists them. "Stopped" here means present-but-not-running +async function waitForTunnelStopped(name, { maxMs = 15000, pollMs = 500 } = {}) { + const deadline = Date.now() + maxMs; + while (Date.now() < deadline) { + try { + const list = await daemon.ipcRequest(sandbox, 'GET', '/tunnels'); + if (list.status === 200 && Array.isArray(list.json)) { + const t = list.json.find((x) => x.tunnelconfig && x.tunnelconfig.name === name); + if (!t) return { gone: true, entry: null }; + const state = t.status && t.status.state; + if (state && state !== 'running' && state !== 'live' && state !== 'starting') { + return { gone: false, entry: t }; + } + } + } catch {} + await sleep(pollMs); + } + return null; +} + async function fetchJson(url, opts = {}) { const res = await fetch(url, { redirect: 'manual', @@ -133,19 +178,44 @@ async function fetchJson(url, opts = {}) { return { status: res.status, headers: res.headers, text, json }; } +function getBinary() { + if (!binaryPath) throw new Error('binaryPath not set (call setBinary first)'); + return binaryPath; +} + module.exports = { SERVER, workDir, + sandbox, sleep, setBinary, + getBinary, getPublicIp, SkipCase, runCase, getResults, withTunnel, withEcho, + spawnCli, + forceKill, + killProc, + dumpLogs, + waitForTunnelByName, + waitForTunnelStopped, pickHttpsUrl, pickHttpUrl, pickProtoUrl, fetchJson, + runSubcommand: (args, opts) => daemon.runSubcommand(getBinary(), args, opts), + startDaemon: () => daemon.startDaemon(getBinary(), sandbox), + stopDaemon: (opts) => daemon.stopDaemon(getBinary(), sandbox, opts), + withDaemon: (fn) => daemon.withDaemon(getBinary(), sandbox, fn), + readDaemonInfo: () => daemon.readDaemonInfo(sandbox), + readDaemonState: () => daemon.readDaemonState(sandbox), + readDaemonConfig: () => daemon.readDaemonConfig(sandbox), + waitForDaemonInfo: (maxMs) => daemon.waitForDaemonInfo(sandbox, maxMs), + waitForDaemonGone: (maxMs) => daemon.waitForDaemonGone(sandbox, maxMs), + ipcRequest: (method, route, body) => daemon.ipcRequest(sandbox, method, route, body), + isPidAlive: daemon.isPidAlive, + stripAnsi: daemon.stripAnsi, }; diff --git a/test/e2e/lib/sandbox.cjs b/test/e2e/lib/sandbox.cjs new file mode 100644 index 0000000..06d0374 --- /dev/null +++ b/test/e2e/lib/sandbox.cjs @@ -0,0 +1,59 @@ +const fs = require('fs'); +const path = require('path'); + +const isWindows = process.platform === 'win32'; +const isMac = process.platform === 'darwin'; + +function rm(p) { + try { fs.rmSync(p, { recursive: true, force: true }); } catch {} +} + +function createSandbox(rootDir) { + const home = path.join(rootDir, 'home'); + fs.mkdirSync(home, { recursive: true }); + + const env = { + HOME: home, + USERPROFILE: home, + XDG_CONFIG_HOME: path.join(home, '.config'), + XDG_STATE_HOME: path.join(home, '.local', 'state'), + APPDATA: path.join(home, 'AppData', 'Roaming'), + LOCALAPPDATA: path.join(home, 'AppData', 'Local'), + }; + + for (const dir of [env.XDG_CONFIG_HOME, env.XDG_STATE_HOME, env.APPDATA, env.LOCALAPPDATA]) { + fs.mkdirSync(dir, { recursive: true }); + } + + function pinggyConfigDir() { + if (isWindows) return path.join(env.APPDATA, 'pinggy'); + return path.join(env.XDG_CONFIG_HOME, 'pinggy'); + } + function pinggyLogDir() { + if (isWindows) return path.join(env.LOCALAPPDATA, 'Pinggy-CLI', 'Logs'); + if (isMac) return path.join(home, 'Library', 'Logs', 'Pinggy-CLI'); + return path.join(env.XDG_STATE_HOME, 'pinggy-cli', 'logs'); + } + + return { + home, + env, + pinggyConfigDir, + pinggyLogDir, + daemonJsonPath: () => path.join(pinggyConfigDir(), 'daemon.json'), + daemonStatePath: () => path.join(pinggyConfigDir(), 'daemon-state.json'), + daemonConfigPath: () => path.join(pinggyConfigDir(), 'daemon-config.json'), + tunnelsConfigDir: () => path.join(pinggyConfigDir(), 'tunnels'), + daemonLogPath: () => path.join(pinggyLogDir(), 'daemon.log'), + tunnelLogDir: () => path.join(pinggyLogDir(), 'tunnels'), + reset() { + rm(pinggyConfigDir()); + rm(pinggyLogDir()); + }, + cleanup() { + rm(home); + }, + }; +} + +module.exports = { createSandbox }; diff --git a/test/e2e/run.cjs b/test/e2e/run.cjs index 86d570c..3fd540d 100644 --- a/test/e2e/run.cjs +++ b/test/e2e/run.cjs @@ -15,6 +15,24 @@ if (!fs.existsSync(binaryPath)) { setBinary(binaryPath); const cases = [ + // Daemon lifecycle + require('./cases/daemon-start-stop.cjs'), + require('./cases/daemon-status.cjs'), + require('./cases/daemon-stale-pid.cjs'), + + // Config CRUD + require('./cases/config-save-list.cjs'), + require('./cases/config-save-full.cjs'), + require('./cases/config-update.cjs'), + require('./cases/config-delete.cjs'), + require('./cases/config-name-validation.cjs'), + require('./cases/config-auto-toggle.cjs'), + + // IPC direct + require('./cases/ipc-http.cjs'), + require('./cases/ipc-loglevel.cjs'), + + // Legacy single-tunnel flag behaviors (network) require('./cases/serve.cjs'), require('./cases/headers.cjs'), require('./cases/basic-auth.cjs'), @@ -26,6 +44,20 @@ const cases = [ require('./cases/udp.cjs'), require('./cases/config-roundtrip.cjs'), require('./cases/debugger-ws.cjs'), + + // Subcommand-driven tunnel behaviors (network, via daemon) + require('./cases/start-background.cjs'), + require('./cases/ps-output.cjs'), + require('./cases/stop-resolution.cjs'), + require('./cases/restart.cjs'), + + // Foreground/detached lifecycle (network, via daemon) + require('./cases/foreground-grace-stops.cjs'), + require('./cases/detached-survives-cli-exit.cjs'), + + // Crash recovery and clean shutdown (network) + require('./cases/clean-shutdown-clears-state.cjs'), + require('./cases/crash-recovery-detached.cjs'), ]; async function main() { diff --git a/tsconfig.json b/tsconfig.json index 83981e2..b364505 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,9 +3,12 @@ "target": "es2020", "module": "nodenext", "moduleResolution": "nodenext", + "rootDir": "./src", "esModuleInterop": true, "forceConsistentCasingInFileNames": true, "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, "skipLibCheck": true, "outDir": "dist_tsc", "lib": ["es2017", "dom"],