pinggy 0.5.5 → 0.5.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,596 @@
1
+ x# Pinggy CLI Architecture
2
+
3
+ Reference for developers and AI agents working on the `@pinggy/cli` codebase. Covers the two-process daemon model, IPC, state, session ownership, and module layout introduced on the `feat/daemon` branch.
4
+
5
+ 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.
6
+
7
+ ## 1. The two-process model
8
+
9
+ The `pinggy` binary has three execution modes, dispatched in `src/main.ts`:
10
+
11
+ | Mode | Trigger | Entry | Purpose |
12
+ | -------------------- | -------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
13
+ | Daemon child | `--_daemon-child` flag | `runDaemonChild()` in `src/daemon/daemonChild.ts` | Long-running background process. Owns the `TunnelManager` and the SDK. |
14
+ | 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`. |
15
+ | 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:...`. |
16
+
17
+ The CLI never opens an SDK tunnel itself. Every tunnel is created inside the daemon. CLI processes talk to the daemon over HTTP + WebSocket on `127.0.0.1`.
18
+
19
+ ![1779362797675](image/ARCHITECTURE/1779362797675.png)
20
+
21
+ A given user has **one** daemon. Whichever client (CLI or App) finds no live daemon spawns one; later clients discover it via `daemon.json`.
22
+
23
+ ## 2. Filesystem layout
24
+
25
+ State lives under `getPinggyConfigDir()` (`src/utils/configDir.ts`):
26
+
27
+ - Linux: `$XDG_CONFIG_HOME/pinggy` (default `~/.config/pinggy`)
28
+ - macOS: `~/.config/pinggy`
29
+ - Windows: `%APPDATA%/pinggy`
30
+
31
+ Logs live under `getPinggyLogDir()`:
32
+
33
+ - Linux: `$XDG_STATE_HOME/pinggy-cli/logs`
34
+ - macOS: `~/Library/Logs/Pinggy-CLI`
35
+ - Windows: `%LOCALAPPDATA%/Pinggy-CLI/Logs`
36
+
37
+ ```
38
+ <configDir>/
39
+ ├── daemon.json Daemon discovery: {pid, port, startedAt}
40
+ ├── daemon-state.json Active tunnel snapshot for crash recovery
41
+ └── tunnels/
42
+ └── <name>_<configId>.json Saved tunnel configs (from `pinggy config save`)
43
+
44
+ <logDir>/
45
+ ├── daemon.log Daemon process log (rolled)
46
+ └── tunnels/
47
+ └── <origin>__<name>__<tunnelId>.log Per-tunnel logs (rolled 10MB×3)
48
+ ```
49
+
50
+ Helpers live in `src/utils/configDir.ts`: `getDaemonInfoPath`, `getDaemonLogPath`, `getTunnelLogPath`, `getTunnelConfigDir`.
51
+
52
+ ## 3. Daemon discovery and spawn
53
+
54
+ 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.
55
+
56
+ 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.
57
+
58
+ ```
59
+ CLI starts
60
+ │
61
+ ▼
62
+ getDaemonInfo() ─── reads daemon.json
63
+ │
64
+ ├─ file missing ──▶ return null
65
+ ├─ malformed ──▶ return null
66
+ ├─ PID dead (kill -0 fails)──▶ unlink daemon.json, return null
67
+ └─ PID alive ──▶ return DaemonInfo
68
+ │
69
+ ▼
70
+ has DaemonInfo? ─── yes ──▶ connect IPCClient(port)
71
+ │
72
+ no
73
+ ▼
74
+ startDaemon()
75
+ │
76
+ ├─ spawn detached child with --_daemon-child
77
+ │ Unix: spawn(execPath, [argv[1], "--_daemon-child"], {detached, stdio: pipe-stderr})
78
+ │ Windows: same + windowsHide:true
79
+ │ Electron: calls ensuredaemon() which internally starts the daemon server if no daemon is there
80
+ │
81
+ ├─ child.unref()
82
+ │
83
+ └─ pollForDaemonInfo(8000ms, interval 200ms)
84
+ │
85
+ ├─ daemon.json appears ──▶ return DaemonInfo
86
+ ├─ child exits early ──▶ throw with stderr
87
+ └─ timeout ──▶ throw "Daemon failed to start within timeout"
88
+ ```
89
+
90
+ Implementation: `src/daemon/daemonManager.ts`.
91
+
92
+ The race between two CLIs invoked at the same moment is bounded but not eliminated: both may observe no `daemon.json`, both may spawn a child. Whichever child's atomic write lands last wins the file; the other daemon is reachable via its port but unreachable through discovery, and dies when its IPC server is closed or its parent CLI exits. In practice this is rare enough that we accept it; tightening it would require a file lock and adds more complexity than it removes.
93
+
94
+ ## 4. Daemon child startup sequence
95
+
96
+ `runDaemonChild()` runs once per daemon lifetime, inside the spawned child process. The step order matters: signal handlers must register before any work that could throw, the IPC port must be open before `daemon.json` is written, and crash recovery must run before auto-start so a restored tunnel never races a freshly-spawned auto-start one.
97
+
98
+ `runDaemonChild()` in `src/daemon/daemonChild.ts`:
99
+
100
+ ```
101
+ 1. ensurePinggyConfigDir(), ensurePinggyLogDir()
102
+ 2. Configure winston logger → file: daemon.log, stdout: false
103
+ 3. Create TunnelManager singleton
104
+ 4. Create IPCServer (HTTP + WS, port not yet bound)
105
+ 5. Create SessionTracker, wire onSessionDisconnect
106
+ 6. Register signal handlers: SIGTERM, SIGINT, exit, uncaughtException
107
+ 7. ipcServer.listen() → returns OS-assigned port
108
+ 8. Write daemon.json atomically (write to .tmp, rename)
109
+ 9. restoreCrashedTunnels() — replay detached tunnels from daemon-state.json
110
+ 10. Start auto-start tunnels (getAutoStartConfigs())
111
+ 11. Log "Daemon ready"
112
+
113
+
114
+ ```
115
+
116
+ Step 8 is the contract the rest of the system depends on: once `daemon.json` exists with a live PID, the IPC server is already accepting connections. CLIs that read the file can immediately POST or open a WebSocket; there is no "warming up" window. Steps 9 and 10 may take time when the Pinggy edge is slow, so "Daemon ready" only means the daemon is reachable, not that every tunnel is live. Callers that need tunnel readiness must poll `/tunnels` or subscribe to `url_ready` over the WebSocket.
117
+
118
+ ## 5. IPC: HTTP routes
119
+
120
+ 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.
121
+
122
+ Server: `src/daemon/ipcServer.ts`. Client: `src/daemon/ipcClient.ts`. Public facade: `src/daemon/tunnelClient.ts`.
123
+
124
+ | Method | Path | Purpose |
125
+ | ------ | ----------------------------------------------------------- | ----------------------------------------------- |
126
+ | GET | `/ping` | Health + uptime |
127
+ | GET | `/tunnels` | List V2 |
128
+ | GET | `/tunnels/:id` | Get one |
129
+ | POST | `/tunnels/start` | Start by saved-config name |
130
+ | POST | `/tunnels/start-config` | Start with inline V2 config |
131
+ | POST | `/tunnels/stop` | `{tunnelid}` |
132
+ | POST | `/tunnels/restart` | `{tunnelid}` |
133
+ | GET | `/tunnels-v1` | List V1 (compat) |
134
+ | POST | `/tunnels/start-v1` | V1 start (used by remote management) |
135
+ | POST | `/tunnels/update-config` | V1 update |
136
+ | POST | `/tunnels/update-config-v2` | V2 update |
137
+ | POST | `/tunnels/remove-stopped` | Remove a stopped tunnel by tunnelid or configId |
138
+ | GET | `/loglevel`, POST `/loglevel` | Daemon-wide log level |
139
+ | GET | `/config/tunnel-logging`, POST `/config/tunnel-logging` | Tunnel logging toggle |
140
+ | GET | `/logs/paths` | All tunnel log file paths + running flags |
141
+ | GET | `/logs/resolve?q=` | Resolve a name/id/configId to a log path |
142
+ | POST | `/shutdown` | Graceful shutdown |
143
+
144
+ Conventions:
145
+
146
+ - Every request sets `X-Pinggy-Origin: app|cli|remote` (defaulted in `parseOrigin()` to `cli`). Stored on the tunnel and used in log filenames.
147
+ - V2 is the canonical shape. V1 routes (`/tunnels-v1`, `/tunnels/start-v1`, `/tunnels/update-config`) exist for remote management payloads that still use the older schema. New code should target V2.
148
+ - `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.
149
+ - 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`.
150
+ - 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.
151
+
152
+ ## 6. IPC: WebSocket event stream
153
+
154
+ Path: `ws://127.0.0.1:<port>/ws`. Schema in `src/daemon/wsProtocol.ts`.
155
+
156
+ Client → Daemon:
157
+
158
+ ```
159
+ { type: "subscribe", tunnelId: "...", mode: "foreground" | "detached" }
160
+ { type: "unsubscribe", tunnelId: "..." }
161
+ ```
162
+
163
+ Daemon → Client (always wrapped in a `tunnel_event`):
164
+
165
+ ```
166
+ {
167
+ type: "tunnel_event",
168
+ tunnelId: "...",
169
+ event: "url_ready" | "stats" | "disconnect" | "reconnecting" | "reconnected"
170
+ | "reconnection_failed" | "error" | "stopped" | "will_reconnect"
171
+ | "worker_error" | "subscribed" | "error_response",
172
+ payload: { ... }
173
+ }
174
+ ```
175
+
176
+ 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`.
177
+
178
+ ```
179
+ Subscribe sequence:
180
+
181
+ CLI Daemon TunnelManager
182
+ │ ws.send(subscribe) │ │
183
+ ├──────────────────────────────▶ │
184
+ │ │ registerStatsListener │
185
+ │ ├───────────────────────────────▶│
186
+ │ │ registerDisconnectListener │
187
+ │ ├───────────────────────────────▶│
188
+ │ │ (… 7 more listeners) │
189
+ │ │ │
190
+ │ │ commit subscription │
191
+ │ │ sessionTracker.attach(tid, sid, mode)
192
+ │ tunnel_event "subscribed" │ │
193
+ ◀──────────────────────────────┤ │
194
+ │ │ stats fired ◀────────────────│
195
+ │ tunnel_event "stats" │ │
196
+ ◀──────────────────────────────┤ │
197
+ ```
198
+
199
+ The CLI side dispatches each `tunnel_event` to user-registered callbacks via `TunnelClient.handleWsMessage`. Callbacks are stored per event type in arrays (`callbacks.stats`, `callbacks.disconnect`, etc.)
200
+
201
+ ## 7. Foreground vs detached: SessionTracker
202
+
203
+ `SessionTracker` in `src/daemon/sessionTracker.ts` tracks ownership of each running tunnel.
204
+
205
+ ```
206
+ ownership: Map<tunnelId, { sessionId, mode: "foreground" | "detached" }>
207
+ graceTimers: Map<tunnelId, NodeJS.Timeout>
208
+ ```
209
+
210
+ State machine for a tunnel:
211
+
212
+ ```
213
+ subscribe(mode="foreground")
214
+ ┌──────────────────────────────────────────────┐
215
+ │ ▼
216
+ (no owner) FOREGROUND
217
+ │ ▲ │
218
+ │ │ │ WS session disconnects
219
+ │ │ ▼
220
+ │ │ GRACE (5000ms)
221
+ │ │ subscribe(mode="foreground") │
222
+ │ │ (another CLI re-attaches) │
223
+ │ │ ◀────────────────────────────────────────┤
224
+ │ │ │ timer expires
225
+ │ │ ▼
226
+ │ └─────── manager.stopTunnel(tunnelId) ◀────┘
227
+ │
228
+ │ subscribe(mode="detached")
229
+ │ or remote-management start
230
+ ▼
231
+ DETACHED (session disconnect has no effect)
232
+ │
233
+ │ /tunnels/stop or daemon shutdown
234
+ ▼
235
+ (removed)
236
+ ```
237
+
238
+ Key invariants:
239
+
240
+ - Foreground tunnels die 5 seconds after their WS session disconnects if nobody re-attaches. Implemented as `startGraceTimer` → `killOrphanedTunnel` in `SessionTracker`.
241
+ - Detached tunnels are immune to session disconnect and persist in `daemon-state.json` for crash recovery.
242
+ - `attach(tunnelId, sessionId, mode)` cancels any pending grace timer for that tunnel.
243
+ - `pinggy attach <name|id>` 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).
244
+
245
+ ## 8. Crash recovery
246
+
247
+ `src/daemon/stateStore.ts` writes `daemon-state.json` after every tunnel start/stop. The schema:
248
+
249
+ ```ts
250
+ interface DaemonState {
251
+ tunnels: DaemonStateTunnel[]; // {tunnelId, configId, name, origin, config, mode, startedAt}
252
+ lastUpdated: string;
253
+ }
254
+ ```
255
+
256
+ Two lifecycle paths:
257
+
258
+ - **Clean shutdown** (`/shutdown` or signal): `clearDaemonState()` deletes the file. Next daemon start sees nothing to recover.
259
+ - **Crash** (SIGKILL, OOM, panic): the file remains. Next daemon start calls `restoreCrashedTunnels()`, which restarts each `mode === "detached"` tunnel using the persisted config and stable `tunnelId` (so log files stay continuous). Foreground tunnels are not restored: there is no CLI to attach.
260
+
261
+ Auto-start tunnels are independent of crash recovery. They come from saved configs marked `autoStart: true` (`getAutoStartConfigs()`), not from the state file.
262
+
263
+ ## 9. Tunnel lifecycle end-to-end
264
+
265
+ Foreground start (`pinggy start <name>`):
266
+
267
+ ```
268
+ CLI: pinggy start my-tunnel
269
+ │
270
+ ▼
271
+ subcommands.handleStart()
272
+ ├─ resolveConfig("my-tunnel") → SavedTunnelConfig
273
+ ├─ buildFinalConfig() with optional flag overrides
274
+ └─ startForegroundViaDaemon(finalConfig)
275
+ │
276
+ ▼
277
+ initClient() → new TunnelClient() → ensureDaemon()
278
+ │ (spawns daemon if missing)
279
+ ▼
280
+ client.handleStartV2(finalConfig) ── HTTP POST /tunnels/start-config
281
+ │ │
282
+ │ ▼
283
+ │ IPCServer.handleStartV2
284
+ │ │
285
+ │ ▼
286
+ │ TunnelManager.createTunnel + startTunnel
287
+ │ │
288
+ │ ▼
289
+ │ attachTunnelLogger(tid, origin, name)
290
+ │ │
291
+ │ ▼
292
+ │ SDK opens tunnel, returns urls
293
+ │ TunnelResponseV2 ◀───────────────────── 200 OK
294
+ ▼
295
+ client.attach(tunnelId, "foreground") ── WS subscribe
296
+ │
297
+ ▼
298
+ connectTui()
299
+ ├─ TTY available + !noTui → blessed TUI
300
+ └─ else → plain stdout
301
+ │
302
+ ▼
303
+ await TUI exit / SIGINT
304
+ │
305
+ ▼
306
+ client.handleStop(tunnelId) ── HTTP POST /tunnels/stop
307
+ client.close() ── WS close
308
+ ```
309
+
310
+ Background start adds `-b` flag → `startBackgroundTunnels()`. The CLI exits immediately after the daemon confirms start; the tunnel keeps running.
311
+
312
+ Attach (`pinggy attach <name|id>`):
313
+
314
+ ```
315
+ attachCommand.handleAttach()
316
+ ├─ ensureDaemon()
317
+ ├─ client.handleListV2()
318
+ ├─ findTunnel(list, nameOrId) (id exact, id prefix, name, configId)
319
+ ├─ client.attach(tunnelId, "foreground") (cancels detached grace timer? no, only foreground grace)
320
+ └─ connectTui()
321
+ ```
322
+
323
+ The first time a detached tunnel is attached, the SessionTracker entry flips to `foreground` while the WS session is open. When the TUI quits, the user can choose to stop the tunnel (default in `connectTui`'s `onExit`).
324
+
325
+ ## 10. Components by directory
326
+
327
+ ```
328
+ src/
329
+ ├── main.ts Entry dispatch (daemon-child / subcommand / legacy)
330
+ ├── index.ts Library exports for embedding in Electron
331
+ ├── types.ts Shared types: TunnelStatus, FinalConfig, ErrorResponse, enums
332
+ ├── logger.ts, logger/ Winston root + per-tunnel transport attach/detach
333
+ │
334
+ ├── cli/ Foreground CLI surface
335
+ │ ├── subcommands.ts Router for `config|start|stop|ps|attach|daemon|logs|log|restart`
336
+ │ ├── startCli.ts Foreground/background/multi/auto-start orchestration
337
+ │ ├── buildAndStartTunnel.ts Legacy single-tunnel path
338
+ │ ├── buildConfig.ts CLI flags + positionals → FinalConfig
339
+ │ ├── configStore.ts Persisted saved-config CRUD
340
+ │ ├── daemonCommands.ts `pinggy daemon start|stop|status|install-service|...`
341
+ │ ├── attachCommand.ts `pinggy attach`
342
+ │ ├── psCommand.ts `pinggy ps`
343
+ │ ├── stopCommand.ts `pinggy stop`
344
+ │ ├── restartCommand.ts `pinggy restart`
345
+ │ ├── logCommand.ts, logsCommand.ts `pinggy log`, `pinggy logs`
346
+ │ ├── options.ts, extendedOptions.ts, defaults.ts, help.ts
347
+ │
348
+ ├── daemon/ Daemon process + IPC
349
+ │ ├── daemonChild.ts runDaemonChild() entry. Persistence, recovery, auto-start.
350
+ │ ├── daemonManager.ts getDaemonInfo, startDaemon, stopDaemon (spawn/discovery)
351
+ │ ├── ipcServer.ts HTTP + WS endpoints. Owns Routes map + WS sessions.
352
+ │ ├── ipcClient.ts Raw HTTP wrapper (no WS). Internal to tunnelClient.ts only.
353
+ │ ├── tunnelClient.ts PUBLIC facade. TunnelClient + DaemonTunnelHandler.
354
+ │ ├── wsProtocol.ts ClientMessage, TunnelEvent, TunnelEventPayloadMap.
355
+ │ ├── sessionTracker.ts Foreground/detached ownership + grace timer.
356
+ │ ├── stateStore.ts daemon-state.json read/write/clear.
357
+ │ └── serviceInstaller.ts systemd / launchd / Task Scheduler installers.
358
+ │
359
+ ├── tunnel_manager/
360
+ │ └── TunnelManager.ts Singleton wrapping @pinggy/pinggy SDK. Listener fanout.
361
+ │
362
+ ├── remote_management/ Remote control via Pinggy management WS
363
+ │ ├── remoteManagement.ts Connect/disconnect + state machine
364
+ │ ├── handler.ts TunnelHandler interface + TunnelOperations impl
365
+ │ ├── remote_schema.ts Zod schemas for V1 + V2 payloads
366
+ │ ├── websocket_handlers.ts, websocket_printer.ts
367
+ │
368
+ ├── tui/ Foreground UI
369
+ │ ├── blessed/ blessed-based TUI for tunnels
370
+ │ ├── ink/ React/Ink prototypes
371
+ │ └── spinner/ Loading spinner widget
372
+ │
373
+ ├── utils/ Cross-cutting helpers
374
+ │ ├── configDir.ts Per-OS config + log paths
375
+ │ ├── parseArgs.ts CLI arg parser
376
+ │ ├── printer.ts CLIPrinter singleton
377
+ │ ├── getFreePort.ts Free local port for web debugger
378
+ │ ├── FileServer.ts Static file serving
379
+ │ └── htmlTemplates.ts Built-in error pages
380
+ │
381
+ └── workers/
382
+ └── file_serve_worker.ts Worker thread for --serve
383
+ ```
384
+
385
+ ## 11. Key types
386
+
387
+ - `TunnelOrigin = "app" | "cli" | "remote"` (`src/tunnel_manager/TunnelManager.ts`). Set on every tunnel at creation; flows into log filenames.
388
+ - `ClientOrigin = "app" | "cli" | "remote"` (`src/daemon/ipcClient.ts`). Same values, used in the `X-Pinggy-Origin` header.
389
+ - `TunnelStateType` (`src/types.ts`): `idle | starting | running | live | closed | exited`.
390
+ - `FinalConfig` (`src/types.ts`): the SDK's `TunnelConfigurationV1` plus `conf?` and `saveconf?`. Used by `buildConfig.ts` and threaded through to the daemon.
391
+ - `TunnelResponseV2` (`src/remote_management/handler.ts`): `{tunnelid, remoteurls, tunnelconfig, status, stats, greetmsg?}`. The canonical wire shape.
392
+ - `DaemonInfo` (`src/daemon/daemonChild.ts`): `{pid, port, startedAt}`. The contents of `daemon.json`.
393
+ - `DaemonStateTunnel` (`src/daemon/stateStore.ts`): `{tunnelId, configId, name, origin, config, mode, startedAt}`. Crash recovery row.
394
+
395
+ ## 12. Singletons and listener maps
396
+
397
+ The daemon process holds these singletons:
398
+
399
+ - `TunnelManager.getInstance()`. Wraps the SDK. Holds `Map<tunnelId, ManagedTunnel>`. All tunnel state lives here.
400
+ - `logger` (winston). Global root logger. Per-tunnel `File` transports are added/removed by `attachTunnelLogger` / `detachTunnelLogger` in `src/logger/tunnelLogger.ts`, gated by `isTunnelLoggingEnabled()`.
401
+ - `CLIPrinter`. Only used by CLI process for foreground output. Daemon does not print to stdout.
402
+
403
+ Listener registration pattern (used by TunnelManager and propagated by IPCServer):
404
+
405
+ ```ts
406
+ Map<tunnelId, Map<listenerId, (...) => void>>
407
+ ```
408
+
409
+ Register returns a `listenerId`; deregister takes it back. The IPCServer remembers `${type}:${listenerId}` strings in `session.listenerIds.get(tunnelId)` so a session can deregister everything cleanly on close.
410
+
411
+ ## 13. Origin tagging and log file naming
412
+
413
+ 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)`:
414
+
415
+ ```
416
+ <origin>__<name>__<tunnelId>.log when name present
417
+ <origin>__<tunnelId>.log when nameless
418
+ ```
419
+
420
+ Example: `cli__my-api__abc123.log`, `app__1700000000_xyz789.log`, `remote__staging__def456.log`.
421
+
422
+ 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.
423
+
424
+ ## 14. Logging architecture
425
+
426
+ Three log artifacts:
427
+
428
+ | Log | Writer | Path | Lifetime |
429
+ | --------------- | ------------------------------------- | ----------------------------------------------------- | ----------------------------- |
430
+ | Application log | Electron host via `electron-log` | `<userData>/logs/` | Persistent, rolled |
431
+ | Daemon log | winston root inside daemon | `<logDir>/daemon.log` | Persistent, rolled |
432
+ | Tunnel log | winston `File` transport per tunnel | `<logDir>/tunnels/<origin>__<name>__<tunnelId>.log` | While tunnel runs (10MB × 3) |
433
+
434
+ Toggle: `setTunnelLoggingEnabled(false)` detaches every per-tunnel transport immediately. Tunnels keep running; nothing else changes. Re-enabling does not retroactively re-attach to running tunnels (transports are recreated on next tunnel start).
435
+
436
+ CLI logs (the short-lived foreground process) go to stdout by default. The daemon log captures everything inside the long-running process.
437
+
438
+ ## 15. Remote management integration
439
+
440
+ `src/remote_management/remoteManagement.ts` opens a WebSocket to Pinggy's management endpoint and forwards tunnel commands. The receiver implements `TunnelHandler`. Two implementations exist:
441
+
442
+ - `TunnelOperations` (`src/remote_management/handler.ts`). Direct, in-process. Used when remote management runs **inside the daemon** (not the default in CLI).
443
+ - `DaemonTunnelHandler` (`src/daemon/tunnelClient.ts`). Proxy. Forwards every `handleStart`, `handleStop`, etc. over HTTP to the daemon. Used when remote management runs **inside the CLI process** (the current default).
444
+
445
+ `TunnelClient.forRemoteManagement()` returns a `DaemonTunnelHandler` with `origin = "remote"`, so tunnels created via remote management are tagged distinctly and logged separately.
446
+
447
+ Same code path as user-typed subcommands: the daemon does not know or care that the request came from remote management.
448
+
449
+ ## 16. Service installation
450
+
451
+ `pinggy daemon install-service` writes a platform-specific service file and enables it:
452
+
453
+ - Linux: systemd user unit at `~/.config/systemd/user/pinggy.service`. `Type=simple`, runs `<binary> --_daemon-child` directly (no fork). `systemctl --user enable --now`.
454
+ - macOS: launchd `LaunchAgent` at `~/Library/LaunchAgents/io.pinggy.agent.plist`. `launchctl load`.
455
+ - Windows: Task Scheduler entry.
456
+
457
+ The installer resolves the binary via `resolveBinary()` in `src/daemon/serviceInstaller.ts`. Prefers `process.execPath` when running as a pkg binary, falls back to `which pinggy`, then to `node <script>`.
458
+
459
+ A system service gives restart-on-failure and start-at-login. The plain daemon mode does not.
460
+
461
+ ## 17. Reference for AI agents
462
+
463
+ When making changes, these are the load-bearing files and the contracts that matter.
464
+
465
+ **Entry points**
466
+
467
+ - `src/main.ts` — argv dispatch. Adding a new top-level subcommand means updating `SUBCOMMANDS` in `src/cli/subcommands.ts` AND adding a handler. Updating the dispatch in `main.ts` is rarely needed.
468
+ - `src/daemon/daemonChild.ts` — daemon process entry. Anything that needs to run **once** when the daemon boots goes here.
469
+
470
+ **Adding a new IPC route**
471
+
472
+ 1. Add the route key + handler in `IPCServer.registerRoutes()` (`src/daemon/ipcServer.ts`).
473
+ 2. Add the wire method to `IPCClient` (`src/daemon/ipcClient.ts`).
474
+ 3. Expose it on `TunnelClient` (`src/daemon/tunnelClient.ts`) — never let callers reach `IPCClient` directly.
475
+ 4. If it streams events, add the event type to `DaemonEventType` and payload to `TunnelEventPayloadMap` in `src/daemon/wsProtocol.ts`, register the listener in `handleSubscribe`, deregister in `deregisterListeners`, dispatch in `TunnelClient.handleWsMessage`.
476
+
477
+ **Adding a new tunnel state**
478
+
479
+ If new state needs to survive a daemon crash, extend `DaemonStateTunnel` in `src/daemon/stateStore.ts` and update `restoreCrashedTunnels()` in `daemonChild.ts`. If it is only needed in memory, put it on `ManagedTunnel` in `src/tunnel_manager/TunnelManager.ts`.
480
+
481
+ **Foreground vs detached**
482
+
483
+ If you spawn a tunnel and want it to die when the CLI quits, subscribe with `mode: "foreground"`. If it should outlive the CLI, use `mode: "detached"` (and the daemon will write it to `daemon-state.json` for crash recovery).
484
+
485
+ **Origin**
486
+
487
+ Every tunnel must have a correct origin. The default in `parseOrigin()` is `cli`. If you add a new client surface, plumb a new origin value through `TunnelOrigin` and `ClientOrigin`. Origin affects log filenames; renaming an origin is a breaking change for log viewers.
488
+
489
+ **Singletons in tests**
490
+
491
+ `TunnelManager.instance = undefined` between tests, plus `jest.clearAllMocks()`. See `src/_tests_/` for examples. Use `@jest/globals` imports — there is no global `jest`.
492
+
493
+ **English style**
494
+
495
+ No em-dashes. Short phrases. Present tense. See `CLAUDE.md` § "English Style" before writing user-facing strings, error messages, or docs.
496
+
497
+ **Gotchas**
498
+
499
+ - Do not log to stdout from the daemon. The daemon's stdio is closed by `spawn({stdio: "ignore"})` on most platforms; writes will silently fail or fill an OS buffer.
500
+ - Do not call `process.exit()` inside daemon route handlers. The shutdown route is the only path that should terminate the process, and it does so via `setTimeout(() => process.exit(0), 200)` so the HTTP response can flush first.
501
+ - Do not import from `src/daemon/ipcClient.ts` outside `src/daemon/`. Use `TunnelClient` from `tunnelClient.ts`.
502
+ - The first CLI run on a fresh install will spawn the daemon and pay an 8-second worst-case latency. Subsequent runs reuse the daemon.
503
+ - `daemon.json` writes are atomic (write tmp, rename). Direct partial reads should not happen, but `getDaemonInfo()` still tolerates malformed JSON by returning `null` and triggering a respawn.
504
+ - The IPC server only listens on `127.0.0.1`. Do not change to `0.0.0.0` without adding authentication. The current design assumes loopback isolation.
505
+
506
+ ## 18. Quick diagrams
507
+
508
+ ### Start sequence (foreground, daemon already running)
509
+
510
+ ```
511
+ user CLI Daemon SDK / TunnelManager
512
+ │ pinggy start api │ │
513
+ ├──────────────────────▶ │
514
+ │ │ GET /ping (sanity) │
515
+ │ ├───────────────▶ │
516
+ │ │ ◀───────────── │ {status:"ok"} │
517
+ │ │ POST /tunnels/start-config │
518
+ │ ├───────────────▶│ createTunnel + start │
519
+ │ │ ├──────────────────────▶
520
+ │ │ │ urls │
521
+ │ │ ◀──────────────────────┤
522
+ │ │ 200 TunnelResponseV2 │
523
+ │ │ ◀──────────────│ │
524
+ │ │ ws connect → /ws │
525
+ │ │ ws subscribe(tid, "foreground") │
526
+ │ ├───────────────▶│ register N listeners │
527
+ │ │ tunnel_event "subscribed" │
528
+ │ │ ◀──────────────│ │
529
+ │ TUI render │ │ │
530
+ │ ◀────────────────────│ │ │
531
+ │ │ tunnel_event "stats" (repeated) │
532
+ │ │ ◀──────────────│ │
533
+ │ Ctrl+C │ │
534
+ ├──────────────────────▶ POST /tunnels/stop │
535
+ │ ├───────────────▶│ stopTunnel │
536
+ │ │ ◀──────────────│ │
537
+ │ │ ws close │
538
+ ```
539
+
540
+ ### Crash and recovery
541
+
542
+ ```
543
+ ┌──────────────────────────────────┐
544
+ │ Daemon running, 2 detached │
545
+ │ daemon-state.json: [t1, t2] │
546
+ └──────────────────────────────────┘
547
+ │ SIGKILL / OOM
548
+ ▼
549
+ ┌──────────────────────────────────┐
550
+ │ Process gone, but state file │
551
+ │ still on disk │
552
+ └──────────────────────────────────┘
553
+ │ next pinggy command
554
+ ▼
555
+ getDaemonInfo() returns null (PID dead) ──▶ unlinks daemon.json
556
+ │
557
+ ▼
558
+ startDaemon() spawns new child
559
+ │
560
+ ▼
561
+ runDaemonChild()
562
+ ├─ writeDaemonInfo()
563
+ ├─ restoreCrashedTunnels() ──▶ TunnelManager.createTunnel + startTunnel for t1, t2
564
+ │ using same tunnelid (log continuity)
565
+ ├─ getAutoStartConfigs() ──▶ starts auto-start tunnels
566
+ └─ "Daemon ready"
567
+ ```
568
+
569
+ ### Two clients, one daemon
570
+
571
+ ```
572
+ Time
573
+ │
574
+ │ 10:00 Electron app launches.
575
+ │ App finds no daemon → spawns. Daemon starts.
576
+ │ App opens tunnel "web" with origin=app.
577
+ │ log: app__web__t1.log
578
+ │
579
+ │ 10:05 User runs `pinggy ps` from terminal.
580
+ │ CLI finds daemon.json → connects (no spawn).
581
+ │ Both tunnels (including the App's) appear in ps.
582
+ │
583
+ │ 10:06 User runs `pinggy start api`.
584
+ │ Origin=cli. Foreground TUI opens.
585
+ │ log: cli__api__t2.log
586
+ │
587
+ │ 10:10 User Ctrl+C's the CLI tunnel.
588
+ │ CLI sends /tunnels/stop. Daemon stops t2.
589
+ │ Daemon keeps running. App's tunnel t1 unaffected.
590
+ │
591
+ │ 10:30 User closes Electron app.
592
+ │ App calls /shutdown? No — App leaves the daemon up
593
+ │ since CLI/remote may still use it.
594
+ │ Daemon keeps running with t1 still live.
595
+ ▼
596
+ ```
package/CLAUDE.md CHANGED
@@ -48,7 +48,7 @@ CLI code calls `TunnelClient` (`src/daemon/tunnelClient.ts`), the public facade
48
48
 
49
49
  Single daemon per user. State lives under `~/.config/pinggy/` on Linux/macOS or `%APPDATA%/pinggy/` on Windows (helper: `src/utils/configDir.ts`):
50
50
 
51
- - `daemon.json`: `{pid, port, startedAt}`.
51
+ - `daemon.json`: `{pid, port, startedAt, host, ipcVersion}`. `ipcVersion` is `IPC_VERSION` from `src/daemon/ipc/ipcRoutes.ts`. `ensureDaemonRunning()` refuses a live daemon whose version differs and tells the user to run `pinggy daemon stop`. Bump `IPC_VERSION` on any incompatible IPC or WebSocket change.
52
52
  - `daemon-state.json`: detached tunnel configs for crash recovery (`src/daemon/stateStore.ts`). Deleted on clean shutdown; replayed on next start.
53
53
  - `daemon.log`: SDK + daemon logs. CLI logs stay separate.
54
54
  - `tunnels/<name>_<configId>.json`: saved tunnel configs from `pinggy config save`.
package/README.md CHANGED
@@ -324,7 +324,7 @@ Extended options provide advanced controls. Specify them as positional values li
324
324
  - x:reverseproxy Disable built-in reverse-proxy header injection.
325
325
  - x:xff Add X-Forwarded-For.
326
326
  - x:fullurl | x:fullrequesturl Include original request URL.
327
- - x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1).
327
+ - x:haproxy | x:haproxy:v1 | x:haproxy:v2 Send HAProxy PROXY protocol header to the local server (default v1). TCP tunnels only.
328
328
  - w:<cidr>[,<cidr>...] Whitelist IPs (IPv4 CIDR).
329
329
  - k:<token> Set Bearer token(s) for auth (repeatable).
330
330
  - b:<user:pass> Add Basic Auth credentials (repeatable).
@@ -339,8 +339,8 @@ Examples:
339
339
  - Allow only a local subnet:
340
340
  pinggy w:192.168.1.0/24 -l 8080
341
341
 
342
- - Send the real client IP to a PROXY-protocol-aware backend:
343
- pinggy x:haproxy:v2 -l 8080
342
+ - Send the real client IP to a PROXY-protocol-aware backend (TCP tunnels only):
343
+ pinggy --type tcp x:haproxy:v2 -l 8080
344
344
 
345
345
  To generate advanced CLI arguments, use [Configure from Pinggy.io](https://pinggy.io/)
346
346
 
@@ -3,23 +3,27 @@ import {
3
3
  TunnelClient,
4
4
  TunnelOperations,
5
5
  buildRemoteManagementWsUrl,
6
+ daemonIpcVersion,
6
7
  getDaemonInfo,
7
8
  initiateRemoteManagement,
9
+ ipcMismatchMessage,
8
10
  isDaemonRunning,
9
11
  isErrorResponse,
12
+ isIpcCompatible,
10
13
  startDaemon,
11
14
  startRemoteManagement,
12
15
  stopDaemon
13
- } from "./chunk-UX64XCC2.js";
16
+ } from "./chunk-HS3OI4OL.js";
14
17
  import {
15
18
  readDaemonConfig
16
19
  } from "./chunk-MT44NAXX.js";
17
20
  import {
18
21
  DaemonHost,
22
+ IPC_VERSION,
19
23
  Route,
20
24
  SessionMode,
21
25
  ShutdownStatus
22
- } from "./chunk-YTJEV27N.js";
26
+ } from "./chunk-I4HXBP5G.js";
23
27
  import {
24
28
  ConfigVerb,
25
29
  SUBCOMMANDS,
@@ -137,7 +141,7 @@ function printHelpMessage() {
137
141
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
138
142
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
139
143
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
140
- console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
144
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol, TCP tunnels only (default v1)");
141
145
  console.log(" a:Key:Val Add header");
142
146
  console.log(" u:Key:Val Update header");
143
147
  console.log(" r:Key Remove header");
@@ -840,6 +844,20 @@ function hasRemoteManagement(values) {
840
844
  const token = values["remote-management"];
841
845
  return typeof token === "string" && token.trim().length > 0;
842
846
  }
847
+ function validateHaProxy(finalConfig) {
848
+ if (!finalConfig.haProxy) {
849
+ return null;
850
+ }
851
+ const forwarding = finalConfig.forwarding;
852
+ const entries = Array.isArray(forwarding) ? forwarding : [];
853
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
854
+ if (primaryType === TunnelType.Tcp) {
855
+ return null;
856
+ }
857
+ return new Error(
858
+ `HAProxy PROXY protocol (x:haproxy) can only be used with TCP tunnels, but this tunnel is "${primaryType ?? TunnelType.Http}". Make it a TCP tunnel (--type tcp, or the tcp@ server prefix) or drop x:haproxy.`
859
+ );
860
+ }
843
861
  function buildFinalConfig(values, positionals, baseConfig) {
844
862
  let token;
845
863
  let server;
@@ -902,6 +920,10 @@ function buildFinalConfig(values, positionals, baseConfig) {
902
920
  }
903
921
  normalizeForwardingToArray(finalConfig, type);
904
922
  parseArgs2(finalConfig, remainingPositionals);
923
+ const haProxyErr = validateHaProxy(finalConfig);
924
+ if (haProxyErr instanceof Error) {
925
+ throw haProxyErr;
926
+ }
905
927
  storeJson(finalConfig, saveconf);
906
928
  return finalConfig;
907
929
  }
@@ -1761,9 +1783,13 @@ async function handleDaemon(args) {
1761
1783
  }
1762
1784
  }
1763
1785
  async function handleDaemonStart() {
1764
- if (isDaemonRunning()) {
1765
- const info = getDaemonInfo();
1766
- printer_default.print(pico2.yellow(`Daemon already running (PID ${info?.pid}, port ${info?.port}). Use: pinggy daemon status for details.`));
1786
+ const info = getDaemonInfo();
1787
+ if (info) {
1788
+ if (!isIpcCompatible(info)) {
1789
+ printer_default.error(ipcMismatchMessage(info));
1790
+ process.exit(1);
1791
+ }
1792
+ printer_default.print(pico2.yellow(`Daemon already running (PID ${info.pid}, port ${info.port}). Use: pinggy daemon status for details.`));
1767
1793
  return;
1768
1794
  }
1769
1795
  const autoConfigs = getAutoStartConfigs();
@@ -1776,8 +1802,8 @@ async function handleDaemonStart() {
1776
1802
  printer_default.print(pico2.cyanBright("Starting daemon..."));
1777
1803
  }
1778
1804
  try {
1779
- const info = await startDaemon();
1780
- printer_default.success(`Daemon started PID ${info.pid}.`);
1805
+ const info2 = await startDaemon();
1806
+ printer_default.success(`Daemon started PID ${info2.pid}.`);
1781
1807
  } catch (err) {
1782
1808
  printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
1783
1809
  process.exit(1);
@@ -1814,6 +1840,10 @@ function handleDaemonStatus() {
1814
1840
  printer_default.print(` Started: ${info.startedAt}`);
1815
1841
  printer_default.print(` Uptime: ${uptimeStr}`);
1816
1842
  printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
1843
+ printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
1844
+ if (!isIpcCompatible(info)) {
1845
+ printer_default.print(pico2.yellow(` Warning: ${ipcMismatchMessage(info)}`));
1846
+ }
1817
1847
  }
1818
1848
 
1819
1849
  // src/cli/subcommand/handlers/psCommand.ts
@@ -2592,7 +2622,8 @@ var IPCServer = class {
2592
2622
  [Route.Ping]: () => ({
2593
2623
  status: "ok",
2594
2624
  pid: process.pid,
2595
- uptime: Math.floor((Date.now() - this.startedAt) / 1e3)
2625
+ uptime: Math.floor((Date.now() - this.startedAt) / 1e3),
2626
+ ipcVersion: IPC_VERSION
2596
2627
  }),
2597
2628
  [Route.ListTunnels]: async () => {
2598
2629
  const res = await this.ops.handleListV2();
@@ -3385,7 +3416,8 @@ async function runDaemonChild(opts = {}) {
3385
3416
  pid: process.pid,
3386
3417
  port,
3387
3418
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
3388
- host: daemonHost
3419
+ host: daemonHost,
3420
+ ipcVersion: IPC_VERSION
3389
3421
  };
3390
3422
  writeDaemonInfo(info);
3391
3423
  logger.info("Daemon info written", info);
@@ -3426,7 +3458,7 @@ async function main() {
3426
3458
  const { values, positionals, hasAnyArgs } = parseCliArgs(cliOptions);
3427
3459
  configureLogger(values);
3428
3460
  if (values["_daemon-child"]) {
3429
- const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-ZACX7V6J.js");
3461
+ const { runDaemonChild: runDaemonChild2 } = await import("./daemonChild-STFB5OQL.js");
3430
3462
  await runDaemonChild2();
3431
3463
  return;
3432
3464
  }
@@ -1,9 +1,10 @@
1
1
  import {
2
2
  DaemonHost,
3
3
  IPCClient,
4
+ IPC_VERSION,
4
5
  SessionMode,
5
6
  ShutdownStatus
6
- } from "./chunk-YTJEV27N.js";
7
+ } from "./chunk-I4HXBP5G.js";
7
8
  import {
8
9
  TunnelAlreadyRunningError,
9
10
  TunnelManager,
@@ -256,6 +257,19 @@ var TunnelConfigV1Schema = z.object({
256
257
  haProxy: z.enum([HaProxyVersion.V1, HaProxyVersion.V2]).optional(),
257
258
  serve: z.string().optional(),
258
259
  optional: z.record(z.string(), z.unknown()).optional()
260
+ }).superRefine((config, ctx) => {
261
+ if (!config.haProxy) {
262
+ return;
263
+ }
264
+ const entries = Array.isArray(config.forwarding) ? config.forwarding : [];
265
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
266
+ if (primaryType !== TunnelType.Tcp) {
267
+ ctx.addIssue({
268
+ code: "custom",
269
+ path: ["haProxy"],
270
+ message: `haProxy can only be used with "tcp" tunnels, but the first forwarding rule has type: ${primaryType ?? TunnelType.Http}`
271
+ });
272
+ }
259
273
  });
260
274
  var TUNNEL_CONFIG_V1_KEYS = Object.keys(
261
275
  TunnelConfigV1Schema.shape
@@ -1599,6 +1613,19 @@ function getDaemonInfo() {
1599
1613
  function isDaemonRunning() {
1600
1614
  return getDaemonInfo() !== null;
1601
1615
  }
1616
+ function daemonIpcVersion(info) {
1617
+ return info.ipcVersion ?? 0;
1618
+ }
1619
+ function isIpcCompatible(info) {
1620
+ return daemonIpcVersion(info) === IPC_VERSION;
1621
+ }
1622
+ function ipcMismatchMessage(info) {
1623
+ const versions = `daemon IPC v${daemonIpcVersion(info)}, this CLI expects v${IPC_VERSION}`;
1624
+ if (info.host === DaemonHost.APP) {
1625
+ return `The Pinggy app is running a daemon from a different version (${versions}). Update or restart the Pinggy app.`;
1626
+ }
1627
+ return `A daemon from a different Pinggy CLI version is running (PID ${info.pid}; ${versions}). Run "pinggy daemon stop" and retry. This stops all running tunnels.`;
1628
+ }
1602
1629
  function getDaemonSpawnArgs() {
1603
1630
  return {
1604
1631
  command: process.execPath,
@@ -1645,9 +1672,12 @@ async function startDaemon() {
1645
1672
  }
1646
1673
  async function ensureDaemonRunning() {
1647
1674
  const existing = getDaemonInfo();
1648
- if (existing) return existing;
1675
+ if (existing) {
1676
+ if (!isIpcCompatible(existing)) throw new Error(ipcMismatchMessage(existing));
1677
+ return existing;
1678
+ }
1649
1679
  if (process.versions.electron) {
1650
- const { runDaemonChild } = await import("./daemonChild-ZACX7V6J.js");
1680
+ const { runDaemonChild } = await import("./daemonChild-STFB5OQL.js");
1651
1681
  const handle = await runDaemonChild({
1652
1682
  installSignalHandlers: false,
1653
1683
  exitOnFailure: false,
@@ -1658,7 +1688,8 @@ async function ensureDaemonRunning() {
1658
1688
  pid: handle.pid,
1659
1689
  port: handle.port,
1660
1690
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
1661
- host: DaemonHost.APP
1691
+ host: DaemonHost.APP,
1692
+ ipcVersion: IPC_VERSION
1662
1693
  };
1663
1694
  }
1664
1695
  return startDaemon();
@@ -1669,7 +1700,7 @@ function getInProcessDaemonHandle() {
1669
1700
  async function getActiveTunnelSummaries(origin = "app") {
1670
1701
  const info = getDaemonInfo();
1671
1702
  if (!info) return [];
1672
- const { IPCClient: IPCClient2 } = await import("./ipcClient-XRL2WUUM.js");
1703
+ const { IPCClient: IPCClient2 } = await import("./ipcClient-URHJKNEH.js");
1673
1704
  const client = new IPCClient2(info.port, origin);
1674
1705
  let tunnels;
1675
1706
  try {
@@ -1716,7 +1747,7 @@ async function stopDaemon() {
1716
1747
  if (!info) return { ok: false, error: "No daemon is running." };
1717
1748
  let daemonErrors = [];
1718
1749
  try {
1719
- const { IPCClient: IPCClient2 } = await import("./ipcClient-XRL2WUUM.js");
1750
+ const { IPCClient: IPCClient2 } = await import("./ipcClient-URHJKNEH.js");
1720
1751
  const client = new IPCClient2(info.port);
1721
1752
  const result = await client.shutdown();
1722
1753
  logger.debug("Sent shutdown command to daemon", { result });
@@ -2338,6 +2369,9 @@ export {
2338
2369
  getRemoteManagementState,
2339
2370
  getDaemonInfo,
2340
2371
  isDaemonRunning,
2372
+ daemonIpcVersion,
2373
+ isIpcCompatible,
2374
+ ipcMismatchMessage,
2341
2375
  startDaemon,
2342
2376
  ensureDaemonRunning,
2343
2377
  getInProcessDaemonHandle,
@@ -2,6 +2,7 @@
2
2
  import http from "http";
3
3
 
4
4
  // src/daemon/ipc/ipcRoutes.ts
5
+ var IPC_VERSION = 1;
5
6
  var Route = {
6
7
  Ping: "GET /ping",
7
8
  ListTunnels: "GET /tunnels",
@@ -166,6 +167,7 @@ var IPCClient = class {
166
167
  };
167
168
 
168
169
  export {
170
+ IPC_VERSION,
169
171
  Route,
170
172
  DaemonHost,
171
173
  ShutdownStatus,
@@ -6,12 +6,12 @@ import {
6
6
  trackIPCTunnelStart,
7
7
  trackTunnelStart,
8
8
  trackTunnelStop
9
- } from "./chunk-NBR2L6LW.js";
10
- import "./chunk-UX64XCC2.js";
9
+ } from "./chunk-GA6UTBOU.js";
10
+ import "./chunk-HS3OI4OL.js";
11
11
  import "./chunk-MT44NAXX.js";
12
12
  import {
13
13
  DaemonHost
14
- } from "./chunk-YTJEV27N.js";
14
+ } from "./chunk-I4HXBP5G.js";
15
15
  import "./chunk-YWXADADB.js";
16
16
  import "./chunk-PQSLSIDA.js";
17
17
  import "./chunk-UB26QJ4T.js";
package/dist/index.cjs CHANGED
@@ -2443,6 +2443,19 @@ var init_remote_schema = __esm({
2443
2443
  haProxy: import_zod.z.enum([import_pinggy3.HaProxyVersion.V1, import_pinggy3.HaProxyVersion.V2]).optional(),
2444
2444
  serve: import_zod.z.string().optional(),
2445
2445
  optional: import_zod.z.record(import_zod.z.string(), import_zod.z.unknown()).optional()
2446
+ }).superRefine((config, ctx) => {
2447
+ if (!config.haProxy) {
2448
+ return;
2449
+ }
2450
+ const entries = Array.isArray(config.forwarding) ? config.forwarding : [];
2451
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
2452
+ if (primaryType !== import_pinggy3.TunnelType.Tcp) {
2453
+ ctx.addIssue({
2454
+ code: "custom",
2455
+ path: ["haProxy"],
2456
+ message: `haProxy can only be used with "tcp" tunnels, but the first forwarding rule has type: ${primaryType ?? import_pinggy3.TunnelType.Http}`
2457
+ });
2458
+ }
2446
2459
  });
2447
2460
  TUNNEL_CONFIG_V1_KEYS = Object.keys(
2448
2461
  TunnelConfigV1Schema.shape
@@ -2778,11 +2791,12 @@ var init_handler = __esm({
2778
2791
  });
2779
2792
 
2780
2793
  // src/daemon/ipc/ipcRoutes.ts
2781
- var Route, DaemonHost, ShutdownStatus, SessionMode;
2794
+ var IPC_VERSION, Route, DaemonHost, ShutdownStatus, SessionMode;
2782
2795
  var init_ipcRoutes = __esm({
2783
2796
  "src/daemon/ipc/ipcRoutes.ts"() {
2784
2797
  "use strict";
2785
2798
  init_cjs_shims();
2799
+ IPC_VERSION = 1;
2786
2800
  Route = {
2787
2801
  Ping: "GET /ping",
2788
2802
  ListTunnels: "GET /tunnels",
@@ -3039,7 +3053,7 @@ function printHelpMessage() {
3039
3053
  console.log(" x:noreverseproxy Disable built-in reverse-proxy header injection");
3040
3054
  console.log(" x:localservertls:host Connect to local HTTPS server with SNI");
3041
3055
  console.log(" x:passpreflight Pass CORS preflight requests unchanged");
3042
- console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol (default v1)");
3056
+ console.log(" x:haproxy[:v1|:v2] Enable HAProxy PROXY protocol, TCP tunnels only (default v1)");
3043
3057
  console.log(" a:Key:Val Add header");
3044
3058
  console.log(" u:Key:Val Update header");
3045
3059
  console.log(" r:Key Remove header");
@@ -4645,6 +4659,20 @@ function hasRemoteManagement(values) {
4645
4659
  const token = values["remote-management"];
4646
4660
  return typeof token === "string" && token.trim().length > 0;
4647
4661
  }
4662
+ function validateHaProxy(finalConfig) {
4663
+ if (!finalConfig.haProxy) {
4664
+ return null;
4665
+ }
4666
+ const forwarding = finalConfig.forwarding;
4667
+ const entries = Array.isArray(forwarding) ? forwarding : [];
4668
+ const primaryType = entries.length > 0 ? entries[0].type : void 0;
4669
+ if (primaryType === import_pinggy5.TunnelType.Tcp) {
4670
+ return null;
4671
+ }
4672
+ return new Error(
4673
+ `HAProxy PROXY protocol (x:haproxy) can only be used with TCP tunnels, but this tunnel is "${primaryType ?? import_pinggy5.TunnelType.Http}". Make it a TCP tunnel (--type tcp, or the tcp@ server prefix) or drop x:haproxy.`
4674
+ );
4675
+ }
4648
4676
  function buildFinalConfig(values, positionals, baseConfig) {
4649
4677
  let token;
4650
4678
  let server;
@@ -4707,6 +4735,10 @@ function buildFinalConfig(values, positionals, baseConfig) {
4707
4735
  }
4708
4736
  normalizeForwardingToArray(finalConfig, type);
4709
4737
  parseArgs2(finalConfig, remainingPositionals);
4738
+ const haProxyErr = validateHaProxy(finalConfig);
4739
+ if (haProxyErr instanceof Error) {
4740
+ throw haProxyErr;
4741
+ }
4710
4742
  storeJson(finalConfig, saveconf);
4711
4743
  return finalConfig;
4712
4744
  }
@@ -7496,9 +7528,13 @@ async function handleDaemon(args) {
7496
7528
  }
7497
7529
  }
7498
7530
  async function handleDaemonStart() {
7499
- if (isDaemonRunning()) {
7500
- const info = getDaemonInfo();
7501
- printer_default.print(import_picocolors6.default.yellow(`Daemon already running (PID ${info?.pid}, port ${info?.port}). Use: pinggy daemon status for details.`));
7531
+ const info = getDaemonInfo();
7532
+ if (info) {
7533
+ if (!isIpcCompatible(info)) {
7534
+ printer_default.error(ipcMismatchMessage(info));
7535
+ process.exit(1);
7536
+ }
7537
+ printer_default.print(import_picocolors6.default.yellow(`Daemon already running (PID ${info.pid}, port ${info.port}). Use: pinggy daemon status for details.`));
7502
7538
  return;
7503
7539
  }
7504
7540
  const autoConfigs = getAutoStartConfigs();
@@ -7511,8 +7547,8 @@ async function handleDaemonStart() {
7511
7547
  printer_default.print(import_picocolors6.default.cyanBright("Starting daemon..."));
7512
7548
  }
7513
7549
  try {
7514
- const info = await startDaemon();
7515
- printer_default.success(`Daemon started PID ${info.pid}.`);
7550
+ const info2 = await startDaemon();
7551
+ printer_default.success(`Daemon started PID ${info2.pid}.`);
7516
7552
  } catch (err) {
7517
7553
  printer_default.error(`Failed to start daemon: ${errorMessage(err)}`);
7518
7554
  process.exit(1);
@@ -7549,6 +7585,10 @@ function handleDaemonStatus() {
7549
7585
  printer_default.print(` Started: ${info.startedAt}`);
7550
7586
  printer_default.print(` Uptime: ${uptimeStr}`);
7551
7587
  printer_default.print(` Host: ${info.host === DaemonHost.APP ? "Pinggy app (in-process)" : "standalone (CLI)"}`);
7588
+ printer_default.print(` IPC: v${daemonIpcVersion(info)}`);
7589
+ if (!isIpcCompatible(info)) {
7590
+ printer_default.print(import_picocolors6.default.yellow(` Warning: ${ipcMismatchMessage(info)}`));
7591
+ }
7552
7592
  }
7553
7593
  var import_picocolors6;
7554
7594
  var init_daemonCommandsHandler = __esm({
@@ -8542,7 +8582,8 @@ var init_ipcServer = __esm({
8542
8582
  [Route.Ping]: () => ({
8543
8583
  status: "ok",
8544
8584
  pid: process.pid,
8545
- uptime: Math.floor((Date.now() - this.startedAt) / 1e3)
8585
+ uptime: Math.floor((Date.now() - this.startedAt) / 1e3),
8586
+ ipcVersion: IPC_VERSION
8546
8587
  }),
8547
8588
  [Route.ListTunnels]: async () => {
8548
8589
  const res = await this.ops.handleListV2();
@@ -9357,7 +9398,8 @@ async function runDaemonChild(opts = {}) {
9357
9398
  pid: process.pid,
9358
9399
  port,
9359
9400
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9360
- host: daemonHost
9401
+ host: daemonHost,
9402
+ ipcVersion: IPC_VERSION
9361
9403
  };
9362
9404
  writeDaemonInfo(info);
9363
9405
  logger.info("Daemon info written", info);
@@ -9444,6 +9486,19 @@ function getDaemonInfo() {
9444
9486
  function isDaemonRunning() {
9445
9487
  return getDaemonInfo() !== null;
9446
9488
  }
9489
+ function daemonIpcVersion(info) {
9490
+ return info.ipcVersion ?? 0;
9491
+ }
9492
+ function isIpcCompatible(info) {
9493
+ return daemonIpcVersion(info) === IPC_VERSION;
9494
+ }
9495
+ function ipcMismatchMessage(info) {
9496
+ const versions = `daemon IPC v${daemonIpcVersion(info)}, this CLI expects v${IPC_VERSION}`;
9497
+ if (info.host === DaemonHost.APP) {
9498
+ return `The Pinggy app is running a daemon from a different version (${versions}). Update or restart the Pinggy app.`;
9499
+ }
9500
+ return `A daemon from a different Pinggy CLI version is running (PID ${info.pid}; ${versions}). Run "pinggy daemon stop" and retry. This stops all running tunnels.`;
9501
+ }
9447
9502
  function getDaemonSpawnArgs() {
9448
9503
  return {
9449
9504
  command: process.execPath,
@@ -9490,7 +9545,10 @@ async function startDaemon() {
9490
9545
  }
9491
9546
  async function ensureDaemonRunning() {
9492
9547
  const existing = getDaemonInfo();
9493
- if (existing) return existing;
9548
+ if (existing) {
9549
+ if (!isIpcCompatible(existing)) throw new Error(ipcMismatchMessage(existing));
9550
+ return existing;
9551
+ }
9494
9552
  if (process.versions.electron) {
9495
9553
  const { runDaemonChild: runDaemonChild2 } = await Promise.resolve().then(() => (init_daemonChild(), daemonChild_exports));
9496
9554
  const handle = await runDaemonChild2({
@@ -9503,7 +9561,8 @@ async function ensureDaemonRunning() {
9503
9561
  pid: handle.pid,
9504
9562
  port: handle.port,
9505
9563
  startedAt: (/* @__PURE__ */ new Date()).toISOString(),
9506
- host: DaemonHost.APP
9564
+ host: DaemonHost.APP,
9565
+ ipcVersion: IPC_VERSION
9507
9566
  };
9508
9567
  }
9509
9568
  return startDaemon();
package/dist/index.d.cts CHANGED
@@ -658,6 +658,7 @@ interface PingResponse {
658
658
  status: string;
659
659
  pid: number;
660
660
  uptime: number;
661
+ ipcVersion: number;
661
662
  }
662
663
  type ListTunnelsResponse = (TunnelResponseV2 & {
663
664
  mode?: SessionMode;
@@ -1085,11 +1086,7 @@ declare class TunnelClient {
1085
1086
  * Close the WebSocket connection and cleanup.
1086
1087
  */
1087
1088
  close(): void;
1088
- ping(): Promise<{
1089
- status: string;
1090
- pid: number;
1091
- uptime: number;
1092
- }>;
1089
+ ping(): Promise<PingResponse>;
1093
1090
  handleStartV2(config: object, noWait?: boolean, mode?: SessionMode): Promise<TunnelResponseV2 | ErrorResponse>;
1094
1091
  handleStart(config: object, noWait?: boolean, mode?: SessionMode): Promise<TunnelResponse | ErrorResponse>;
1095
1092
  handleUpdateConfig(config: object, noWait?: boolean): Promise<TunnelResponse | ErrorResponse>;
@@ -1143,6 +1140,8 @@ interface DaemonInfo {
1143
1140
  port: number;
1144
1141
  startedAt: string;
1145
1142
  host?: DaemonHost;
1143
+ /** IPC protocol version the daemon speaks. Absent in daemon.json written by older builds. */
1144
+ ipcVersion?: number;
1146
1145
  }
1147
1146
 
1148
1147
  /**
package/dist/index.d.ts CHANGED
@@ -658,6 +658,7 @@ interface PingResponse {
658
658
  status: string;
659
659
  pid: number;
660
660
  uptime: number;
661
+ ipcVersion: number;
661
662
  }
662
663
  type ListTunnelsResponse = (TunnelResponseV2 & {
663
664
  mode?: SessionMode;
@@ -1085,11 +1086,7 @@ declare class TunnelClient {
1085
1086
  * Close the WebSocket connection and cleanup.
1086
1087
  */
1087
1088
  close(): void;
1088
- ping(): Promise<{
1089
- status: string;
1090
- pid: number;
1091
- uptime: number;
1092
- }>;
1089
+ ping(): Promise<PingResponse>;
1093
1090
  handleStartV2(config: object, noWait?: boolean, mode?: SessionMode): Promise<TunnelResponseV2 | ErrorResponse>;
1094
1091
  handleStart(config: object, noWait?: boolean, mode?: SessionMode): Promise<TunnelResponse | ErrorResponse>;
1095
1092
  handleUpdateConfig(config: object, noWait?: boolean): Promise<TunnelResponse | ErrorResponse>;
@@ -1143,6 +1140,8 @@ interface DaemonInfo {
1143
1140
  port: number;
1144
1141
  startedAt: string;
1145
1142
  host?: DaemonHost;
1143
+ /** IPC protocol version the daemon speaks. Absent in daemon.json written by older builds. */
1144
+ ipcVersion?: number;
1146
1145
  }
1147
1146
 
1148
1147
  /**
package/dist/index.js CHANGED
@@ -14,8 +14,8 @@ import {
14
14
  initiateRemoteManagement,
15
15
  isDaemonRunning,
16
16
  startRemoteManagement
17
- } from "./chunk-UX64XCC2.js";
18
- import "./chunk-YTJEV27N.js";
17
+ } from "./chunk-HS3OI4OL.js";
18
+ import "./chunk-I4HXBP5G.js";
19
19
  import {
20
20
  bulkReplace,
21
21
  configureStorageLogger,
@@ -139,7 +139,7 @@ async function verifyAndLoad() {
139
139
  process.exit(1);
140
140
  }
141
141
  }
142
- await import("./main-KG3Q4LXI.js");
142
+ await import("./main-M5PCTJTB.js");
143
143
  }
144
144
  verifyAndLoad().catch((err) => {
145
145
  printer_default.fatal(`Failed to start CLI:, ${err}`);
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  IPCClient
3
- } from "./chunk-YTJEV27N.js";
3
+ } from "./chunk-I4HXBP5G.js";
4
4
  export {
5
5
  IPCClient
6
6
  };
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  runDaemonChild
4
- } from "./chunk-NBR2L6LW.js";
4
+ } from "./chunk-GA6UTBOU.js";
5
5
  import {
6
6
  RemoteManagementUnauthorizedError,
7
7
  TunnelOperations,
@@ -13,11 +13,11 @@ import {
13
13
  getRemoteManagementState,
14
14
  initiateRemoteManagement,
15
15
  isDaemonRunning
16
- } from "./chunk-UX64XCC2.js";
16
+ } from "./chunk-HS3OI4OL.js";
17
17
  import "./chunk-MT44NAXX.js";
18
18
  import {
19
19
  DaemonHost
20
- } from "./chunk-YTJEV27N.js";
20
+ } from "./chunk-I4HXBP5G.js";
21
21
  import "./chunk-YWXADADB.js";
22
22
  import {
23
23
  TunnelManager
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pinggy",
3
- "version": "0.5.5",
3
+ "version": "0.5.7",
4
4
  "license": "Apache-2.0",
5
5
  "type": "module",
6
6
  "description": "Create secure, shareable tunnels to your localhost and manage them from the command line. ",
@@ -52,7 +52,7 @@
52
52
  ]
53
53
  },
54
54
  "dependencies": {
55
- "@pinggy/pinggy": "^0.5.0",
55
+ "@pinggy/pinggy": "^0.5.1",
56
56
  "blessed": "^0.1.81",
57
57
  "clipboardy": "^5.0.0",
58
58
  "mime": "^4.1.0",