auvezy-terminal-remote 0.4.2 → 0.4.5
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.
- package/README.md +261 -71
- package/dist/cli.js +89 -17
- package/frontend-dist/assets/{eruda-Djm-IyEG.js → eruda-JgMQJMlu.js} +1 -1
- package/frontend-dist/assets/index-CfHptVXT.js +340 -0
- package/frontend-dist/assets/index-DMTUuNcV.css +32 -0
- package/frontend-dist/index.html +2 -2
- package/frontend-dist/sw.js +1 -1
- package/package.json +10 -2
- package/frontend-dist/assets/index-CmMtEAyI.js +0 -335
- package/frontend-dist/assets/index-UEuOXzz9.css +0 -32
package/README.md
CHANGED
|
@@ -1,107 +1,297 @@
|
|
|
1
1
|
# auvezy-terminal-remote
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**English** | [简体中文](https://github.com/jjj201200/auvezy-terminal-remote/blob/main/README.zh-CN.md)
|
|
4
|
+
|
|
5
|
+
> Remote-control any terminal program on your PC (zsh / bash / claude / any CLI)
|
|
6
|
+
> from a phone or tablet browser over LAN.
|
|
4
7
|
>
|
|
5
|
-
>
|
|
8
|
+
> One command — `atr <program>` — and every instance shows up as a tab in your
|
|
9
|
+
> browser's top bar.
|
|
10
|
+
|
|
11
|
+
> **License: [PolyForm Noncommercial 1.0.0](./LICENSE)** —
|
|
12
|
+
> free for personal, educational, and nonprofit use, including modification and
|
|
13
|
+
> redistribution. Commercial use requires a separate license.
|
|
14
|
+
|
|
15
|
+
## What is this
|
|
6
16
|
|
|
7
|
-
|
|
17
|
+
You're on the couch with your phone. A long-running CLI on your PC
|
|
18
|
+
(Claude Code / a deploy script / a debug session…) is doing its thing and
|
|
19
|
+
you want to:
|
|
8
20
|
|
|
9
|
-
|
|
21
|
+
- See its live output (ANSI colors included)
|
|
22
|
+
- Type the next command, hit arrow keys
|
|
23
|
+
- Get a phone notification when Claude triggers an approval hook
|
|
24
|
+
- Not open any port to the public internet, not depend on a cloud service
|
|
10
25
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
- 不开公网、不依赖云
|
|
26
|
+
That's exactly what this project does. PTY output is bridged over WebSocket
|
|
27
|
+
to a webapp; webapp input is bridged back to the PTY. Listens on LAN IPs only,
|
|
28
|
+
authed by token + local cookie.
|
|
15
29
|
|
|
16
|
-
|
|
17
|
-
把 webapp 输入桥回 PTY。仅绑 LAN IP,用 token + 本地 cookie 鉴权。
|
|
30
|
+
## Quick start
|
|
18
31
|
|
|
19
|
-
|
|
32
|
+
### Global install (npm users)
|
|
20
33
|
|
|
21
34
|
```bash
|
|
22
|
-
npm install -g auvezy-terminal-remote
|
|
35
|
+
npm install -g auvezy-terminal-remote # -g is required
|
|
23
36
|
```
|
|
24
37
|
|
|
25
|
-
|
|
38
|
+
> ⚠️ The default `npm i auvezy-terminal-remote` shown at the top right of the
|
|
39
|
+
> npm package page is **missing `-g`**. This is a CLI tool — without `-g` the
|
|
40
|
+
> `atr` binary won't be on your PATH. Use the command above.
|
|
41
|
+
|
|
42
|
+
Then in any terminal:
|
|
26
43
|
|
|
27
44
|
```bash
|
|
28
|
-
atr #
|
|
29
|
-
atr claude #
|
|
30
|
-
atr zsh #
|
|
31
|
-
atr claude --resume foo #
|
|
45
|
+
atr # runs your $SHELL (auto-detects zsh / bash)
|
|
46
|
+
atr claude # runs claude
|
|
47
|
+
atr zsh # runs zsh
|
|
48
|
+
atr claude --resume foo # passes any args through to the child process
|
|
32
49
|
```
|
|
33
50
|
|
|
34
|
-
|
|
51
|
+
After it starts, scan the QR code printed in the terminal — the webapp logs in
|
|
52
|
+
automatically (token lives in `~/.auvezy/terminal-remote/config.json`).
|
|
35
53
|
|
|
36
|
-
|
|
37
|
-
|
|
54
|
+
**Multiple instances**: Run `atr <prog>` in different terminals; each grabs the
|
|
55
|
+
next available port (3000, 3001, 3002…). Tabs for new instances appear in the
|
|
56
|
+
browser's top bar automatically — click to switch.
|
|
38
57
|
|
|
39
58
|
```bash
|
|
40
|
-
atr list #
|
|
41
|
-
atr stop #
|
|
42
|
-
atr attach <url> #
|
|
59
|
+
atr list # list all running instances on this machine
|
|
60
|
+
atr stop # stop all instances on this machine
|
|
61
|
+
atr attach <url> # take over a running instance from the command line
|
|
43
62
|
```
|
|
44
63
|
|
|
45
|
-
|
|
64
|
+
### From source (development or self-build)
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
# GitHub (primary)
|
|
68
|
+
git clone https://github.com/jjj201200/auvezy-terminal-remote.git
|
|
69
|
+
# or Gitee mirror (faster from mainland China)
|
|
70
|
+
git clone https://gitee.com/drowsyflesh/auvezy-terminal-remote.git
|
|
46
71
|
|
|
72
|
+
cd auvezy-terminal-remote
|
|
73
|
+
bash install.sh # checks Node 20+ / pnpm 9+ / build deps → installs → builds
|
|
74
|
+
node backend/dist/cli.js # equivalent to `atr`
|
|
47
75
|
```
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
## Feature matrix
|
|
79
|
+
|
|
80
|
+
| Feature | How it's implemented |
|
|
81
|
+
|---|---|
|
|
82
|
+
| PTY bridge | node-pty + xterm.js 5 |
|
|
83
|
+
| Auth | timingSafeEqual token + Session Cookie (port-bound) |
|
|
84
|
+
| Multi-instance | port-finder auto-increment + cookie-name suffix isolation |
|
|
85
|
+
| Reconnect / replay | OutputBuffer + history_sync (alt-screen filtered by default) |
|
|
86
|
+
| Approval push | Web Push (VAPID, 3 priorities) + iOS Safari LocalNotification fallback |
|
|
87
|
+
| IP drift detection | 30s polling + stability threshold + ip_changed broadcast |
|
|
88
|
+
| Config rewrite | Webapp Settings dialog → /api/config |
|
|
89
|
+
| `attach` subcommand | Master arbitration (webapp > attach > PC) |
|
|
90
|
+
|
|
91
|
+
## Configuration
|
|
92
|
+
|
|
93
|
+
On startup the backend reads `~/.auvezy/terminal-remote/config.json`:
|
|
94
|
+
|
|
95
|
+
```json
|
|
96
|
+
{
|
|
97
|
+
"token": "<64-char hex, auto-generated>",
|
|
98
|
+
"shortcuts": [
|
|
99
|
+
{ "label": "ESC", "data": "" },
|
|
100
|
+
{ "label": "↑", "data": "[A" }
|
|
101
|
+
],
|
|
102
|
+
"command": null,
|
|
103
|
+
"args": null,
|
|
104
|
+
"rateLimitPerMinute": 10,
|
|
105
|
+
"sessionTtlMs": 86400000
|
|
106
|
+
}
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
VAPID keys live in the same directory: `vapid.json` (mode 0o600, auto-generated
|
|
110
|
+
or read from env vars `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY`).
|
|
111
|
+
|
|
112
|
+
Subscriptions are in `push-subscriptions.json`; the multi-instance registry is in
|
|
113
|
+
`instances/<port>.json`.
|
|
114
|
+
|
|
115
|
+
## Startup options
|
|
116
|
+
|
|
68
117
|
```
|
|
118
|
+
atr [subcommand] [options]
|
|
69
119
|
|
|
70
|
-
|
|
120
|
+
Subcommands:
|
|
121
|
+
start start the backend (default)
|
|
122
|
+
attach attach to a running instance from the command line
|
|
123
|
+
list list all running instances on this machine
|
|
124
|
+
stop stop all instances on this machine
|
|
71
125
|
|
|
72
|
-
|
|
126
|
+
Options:
|
|
127
|
+
-p, --port <n> port (default 3000, auto-increments unless -S)
|
|
128
|
+
-S, --strict-port strict port mode: fail if port is taken, no fallback
|
|
129
|
+
--spawn-timeout <s> PTY spawn fallback seconds (default 30; 0 = no timeout;
|
|
130
|
+
first browser connect / Enter / timeout — whichever first)
|
|
131
|
+
--wait-confirm require Enter to spawn (overrides browser/timeout triggers)
|
|
132
|
+
--name <s> instance name (shown in webapp)
|
|
133
|
+
--no-terminal don't print QR code (CI / daemon-friendly)
|
|
134
|
+
--command <cmd> PTY command (default: 'claude')
|
|
135
|
+
--args <json> command args (JSON array string)
|
|
136
|
+
-h, --help show help
|
|
137
|
+
-v, --version show version
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
Environment variables:
|
|
141
|
+
|
|
142
|
+
| Variable | Purpose |
|
|
73
143
|
|---|---|
|
|
74
|
-
| `OCR_COMMAND` |
|
|
75
|
-
| `OCR_ARGS` |
|
|
76
|
-
| `OCR_CWD` |
|
|
77
|
-
| `OCR_ANSI_FILTER` |
|
|
78
|
-
| `OCR_ANSI_FILTER_TUI_NAMES` |
|
|
79
|
-
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` |
|
|
80
|
-
| `PORT` |
|
|
81
|
-
| `STRICT_PORT` |
|
|
82
|
-
| `OCR_SPAWN_TIMEOUT` |
|
|
83
|
-
| `AUTH_TOKEN` |
|
|
84
|
-
| `LOG_LEVEL` | pino
|
|
144
|
+
| `OCR_COMMAND` | Child command (default `$SHELL`, or `/bin/sh`; set to `claude` to run Claude) |
|
|
145
|
+
| `OCR_ARGS` | Command args (JSON array string, e.g. `'["-c","tail -f /dev/null"]'`) |
|
|
146
|
+
| `OCR_CWD` | Child process working directory (default: `process.cwd()`) |
|
|
147
|
+
| `OCR_ANSI_FILTER` | Filter alt-screen output (default `false`). Set `true` for cleaner reconnect replay after vim/htop exits; full-time alt-screen TUIs (claude/tmux/...) are still protected by built-in blocklist |
|
|
148
|
+
| `OCR_ANSI_FILTER_TUI_NAMES` | Append to your own alt-screen TUI blocklist (comma-separated), e.g. `"lazygit,k9s,gh-dash"` |
|
|
149
|
+
| `VAPID_PUBLIC_KEY` / `VAPID_PRIVATE_KEY` | Inject VAPID keys (highest priority, skips file) |
|
|
150
|
+
| `PORT` | Same as `--port` |
|
|
151
|
+
| `STRICT_PORT` | Same as `--strict-port` (set `true` to enable strict mode) |
|
|
152
|
+
| `OCR_SPAWN_TIMEOUT` | Same as `--spawn-timeout` (seconds; 0 = no timeout) |
|
|
153
|
+
| `AUTH_TOKEN` | Specify token (default: auto-generated) |
|
|
154
|
+
| `LOG_LEVEL` | pino level (default `info`) |
|
|
155
|
+
|
|
156
|
+
> Legacy names `CLAUDE_COMMAND` / `CLAUDE_ARGS` / `CLAUDE_CWD` still work
|
|
157
|
+
> (warned once at startup). Renamed to make it clear: this project is not tied
|
|
158
|
+
> to Claude — it can run any PTY program.
|
|
159
|
+
|
|
160
|
+
## Install as a PWA (recommended on mobile)
|
|
161
|
+
|
|
162
|
+
The webapp ships with a manifest. "Add to Home Screen" gives you a near-native
|
|
163
|
+
app experience:
|
|
164
|
+
|
|
165
|
+
- **Android Chrome**: top-right ⋮ → "Install app" (or the address bar shows an
|
|
166
|
+
"Install" prompt)
|
|
167
|
+
- **iOS Safari**: share button → "Add to Home Screen"
|
|
168
|
+
|
|
169
|
+
After install: no browser UI (no address bar, no bottom nav), independent task
|
|
170
|
+
card, status bar matches the app color.
|
|
171
|
+
|
|
172
|
+
> **Web Push limitations**: browsers require Push to be in a secure context
|
|
173
|
+
> (HTTPS / localhost). LAN HTTP (http://192.168.x.x) cannot subscribe to push.
|
|
174
|
+
> The settings panel will display "HTTPS required". Workarounds: use Tailscale /
|
|
175
|
+
> Cloudflare Tunnel to put HTTPS in front of the backend, or deploy with a
|
|
176
|
+
> self-signed certificate.
|
|
177
|
+
|
|
178
|
+
## Running in WSL, accessing from Windows browser
|
|
179
|
+
|
|
180
|
+
WSL2 has two network modes that behave differently:
|
|
181
|
+
|
|
182
|
+
- **Mirrored mode** (Win11 22H2+ default): WSL gets the Windows LAN IP directly
|
|
183
|
+
(e.g. `192.168.x.x`). Windows browsers can use the IP printed on the banner,
|
|
184
|
+
no extra config.
|
|
185
|
+
- **NAT mode** (default): WSL is on `172.x.x.x` private network — Windows
|
|
186
|
+
browsers can't connect directly. The backend detects this on startup and
|
|
187
|
+
prints PowerShell config commands at the end of the banner.
|
|
188
|
+
|
|
189
|
+
**One-shot auto config** (admin PowerShell):
|
|
190
|
+
|
|
191
|
+
```powershell
|
|
192
|
+
# Forward common port range (default 3000–3010)
|
|
193
|
+
.\scripts\wsl-port-forward.ps1
|
|
194
|
+
|
|
195
|
+
# Forward specific ports only
|
|
196
|
+
.\scripts\wsl-port-forward.ps1 -Ports 3000,3001
|
|
197
|
+
|
|
198
|
+
# Register to re-forward on login (no manual re-run when WSL IP changes)
|
|
199
|
+
.\scripts\wsl-port-forward.ps1 -Persist
|
|
200
|
+
|
|
201
|
+
# Cleanup
|
|
202
|
+
.\scripts\wsl-port-forward.ps1 -Reset
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
## Architecture / decisions
|
|
206
|
+
|
|
207
|
+
- Design doc: [`docs/plans/open-claude-remote-clone/design.md`](./docs/plans/open-claude-remote-clone/design.md)
|
|
208
|
+
- Module diagram and data flow: [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md)
|
|
209
|
+
- Architecture decision records (ADRs):
|
|
210
|
+
[`docs/plans/open-claude-remote-clone/adrs/`](./docs/plans/open-claude-remote-clone/adrs/)
|
|
211
|
+
|
|
212
|
+
## Development
|
|
213
|
+
|
|
214
|
+
```bash
|
|
215
|
+
pnpm install
|
|
216
|
+
pnpm dev # backend (tsx watch) + frontend (vite) in parallel
|
|
217
|
+
pnpm test # shared + backend + frontend unit tests
|
|
218
|
+
pnpm typecheck
|
|
219
|
+
pnpm build # full build artifacts (frontend copied into backend/frontend-dist)
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
## Roadmap
|
|
224
|
+
|
|
225
|
+
### Tier 1 (must-have for mobile, low effort, big UX win)
|
|
226
|
+
|
|
227
|
+
1. **Local Echo** (Mosh / Blink / code-server)
|
|
228
|
+
Input lag killer on mobile 4G/weak networks. xterm prediction plugin shows
|
|
229
|
+
keystrokes immediately, PTY response replaces.
|
|
230
|
+
2. **Multi-line paste warning + bracketed paste** (VS Code, Tabby)
|
|
231
|
+
Mobile users paste 5-line commands from WeChat/email — currently goes
|
|
232
|
+
straight to PTY, dangerous. Detect multi-line → confirm dialog.
|
|
233
|
+
3. **Shell Integration subset (OSC 633/133)**
|
|
234
|
+
- Command decorations (green/red dot)
|
|
235
|
+
- Run Recent Command — fuzzy cross-session history quick pick
|
|
236
|
+
- Both extremely friendly on mobile (slow typing → cross-session history
|
|
237
|
+
search is core)
|
|
238
|
+
4. **Auto Reply** (VS Code)
|
|
239
|
+
Match prompt → auto-respond y/N. Mobile users hate typing `[y/N]`.
|
|
240
|
+
5. **Process Revive** (VS Code terminal revive)
|
|
241
|
+
You already have instances.json; serialize scrollback into it. After restart
|
|
242
|
+
webapp can see the previous content. The only hard part on the LAN-only
|
|
243
|
+
route is serialization size — bumping to 5MB is fine.
|
|
85
244
|
|
|
86
|
-
|
|
245
|
+
### Tier 2 (mobile UX bonus)
|
|
87
246
|
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
247
|
+
6. **SmartKeys long-press menu** (Blink)
|
|
248
|
+
On-screen keyboard expansion row: long-press Tab → Shift+Tab; long-press Esc
|
|
249
|
+
→ `^[`; long-press Ctrl → sticky until next key. We already have a Toolbar
|
|
250
|
+
shortcut panel, missing "long-press menu" + "modifier sticky".
|
|
251
|
+
7. **Thumb-drag cursor strip** (Termius: long-press space as trackpad)
|
|
252
|
+
Bottom 8px transparent strip on the terminal area; drag = arrow key
|
|
253
|
+
sequence. Best solution for precise cursor movement on mobile.
|
|
254
|
+
8. **OSC 8 hyperlinks + word-link / file-link** (VS Code)
|
|
255
|
+
xterm.js native LinkProvider — a few lines makes `src/foo.ts:42` clickable.
|
|
256
|
+
9. **Multi-chord shortcuts / modifier sticky** (Tabby, Blink)
|
|
257
|
+
Mobile virtual modifier + Cmd-K Cmd-S two-step combos save more screen than
|
|
258
|
+
a wall of buttons.
|
|
259
|
+
10. **Quick Fixes** (VS Code)
|
|
260
|
+
Scan output, suggest fixes. `fatal: ... --set-upstream` one-click apply.
|
|
261
|
+
High effort but very flashy.
|
|
91
262
|
|
|
92
|
-
|
|
263
|
+
### Tier 3 (write permission / security / collaboration)
|
|
93
264
|
|
|
94
|
-
|
|
265
|
+
11. **Writable / Read-only split** (ttyd -W, gotty -w)
|
|
266
|
+
When multiple devices connect to one instance, others can be set to
|
|
267
|
+
read-only. Very low effort (distinguish at WS handshake).
|
|
268
|
+
12. **Broadcast Input** (Termius: simultaneous input on multiple terminals)
|
|
269
|
+
When multiple webapps connect to one instance, broadcast the same input to
|
|
270
|
+
all PTYs. Easy to add to our multi-instance arch.
|
|
271
|
+
13. **TLS self-signed cert** (ttyd -S, gotty -t)
|
|
272
|
+
HTTPS on LAN lets Web Push API work in more browsers (currently restricted
|
|
273
|
+
on LAN HTTP).
|
|
274
|
+
14. **OAuth / client cert auth** (ttyd client cert)
|
|
275
|
+
On top of our token, add client cert for hardware auth. Low priority —
|
|
276
|
+
token is already enough.
|
|
95
277
|
|
|
96
|
-
|
|
97
|
-
Windows 浏览器可以直接用 banner 上的 IP 访问,无需任何额外配置
|
|
98
|
-
- **NAT 模式**(默认):WSL 在 `172.x.x.x` 私网,Windows 浏览器无法直连。
|
|
99
|
-
backend 启动时会自动检测并在 banner 末尾打印 PowerShell 配置命令
|
|
278
|
+
### Tier 4 (explicitly NOT copying)
|
|
100
279
|
|
|
101
|
-
|
|
280
|
+
- ❌ Plugin system (Tabby): unnecessary for a LAN-only single binary
|
|
281
|
+
- ❌ Cloud Settings Sync (VS Code): conflicts with the LAN-only red line
|
|
282
|
+
- ❌ Sixel/iTerm image protocols: low value on mobile, xterm.js doesn't
|
|
283
|
+
natively support them
|
|
284
|
+
- ❌ asciinema public sharing: conflicts with LAN-only; if anything we'd only
|
|
285
|
+
do local `.cast` export
|
|
286
|
+
- ❌ SFTP/SCP file management (Termius/Wetty): outside the "remote PTY control"
|
|
287
|
+
scope
|
|
288
|
+
- ❌ End-to-end encrypted Vault: home LAN users don't need this
|
|
102
289
|
|
|
103
|
-
|
|
290
|
+
---
|
|
104
291
|
|
|
105
|
-
##
|
|
292
|
+
## Pain points unique to us (others haven't done these)
|
|
106
293
|
|
|
107
|
-
|
|
294
|
+
- **Tailscale / VPN QR code labeling**: we already do dual LAN+Tailscale codes —
|
|
295
|
+
a thoughtful detail on the LAN-only route
|
|
296
|
+
- **Webapp toast notification + iOS LocalNotification fallback**: under iOS PWA
|
|
297
|
+
push restrictions, this fallback strategy isn't considered by anyone else
|
package/dist/cli.js
CHANGED
|
@@ -37,7 +37,7 @@ function isServerMessage(value) {
|
|
|
37
37
|
if (!value || typeof value !== "object")
|
|
38
38
|
return false;
|
|
39
39
|
const type = value.type;
|
|
40
|
-
return type === "terminal_output" || type === "status_update" || type === "history_sync" || type === "heartbeat" || type === "error" || type === "session_ended" || type === "terminal_resize" || type === "ip_changed";
|
|
40
|
+
return type === "terminal_output" || type === "status_update" || type === "history_sync" || type === "heartbeat" || type === "error" || type === "session_ended" || type === "terminal_resize" || type === "ip_changed" || type === "alt_screen_change";
|
|
41
41
|
}
|
|
42
42
|
var init_ws_protocol = __esm({
|
|
43
43
|
"shared/dist/ws-protocol.js"() {
|
|
@@ -1204,6 +1204,19 @@ var init_instance_events = __esm({
|
|
|
1204
1204
|
|
|
1205
1205
|
// backend/dist/api/instance-routes.js
|
|
1206
1206
|
import { Router as Router5 } from "express";
|
|
1207
|
+
async function tryHttpSelfShutdown(target, token) {
|
|
1208
|
+
const url = `http://127.0.0.1:${target.port}/api/instances/self/shutdown?token=${encodeURIComponent(token)}`;
|
|
1209
|
+
const ac = new AbortController();
|
|
1210
|
+
const timer = setTimeout(() => ac.abort(), 3e3);
|
|
1211
|
+
try {
|
|
1212
|
+
const r = await fetch(url, { method: "POST", signal: ac.signal });
|
|
1213
|
+
return r.ok;
|
|
1214
|
+
} catch {
|
|
1215
|
+
return false;
|
|
1216
|
+
} finally {
|
|
1217
|
+
clearTimeout(timer);
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1207
1220
|
function createInstanceRoutes(opts) {
|
|
1208
1221
|
const router = Router5();
|
|
1209
1222
|
const { authModule, registry, currentInstanceId } = opts;
|
|
@@ -1310,6 +1323,18 @@ data: ${JSON.stringify({ instances: items })}
|
|
|
1310
1323
|
res.status(e.httpStatus).json({ error: e.toPayload() });
|
|
1311
1324
|
return;
|
|
1312
1325
|
}
|
|
1326
|
+
if (opts.sharedToken) {
|
|
1327
|
+
const httpOk = await tryHttpSelfShutdown(target, opts.sharedToken);
|
|
1328
|
+
if (httpOk) {
|
|
1329
|
+
try {
|
|
1330
|
+
await registry.unregister(id);
|
|
1331
|
+
} catch {
|
|
1332
|
+
}
|
|
1333
|
+
res.json({ ok: true, outcome: "sigterm" });
|
|
1334
|
+
return;
|
|
1335
|
+
}
|
|
1336
|
+
logger.warn({ id, host: target.host, port: target.port }, "HTTP self-shutdown \u5931\u8D25\uFF0Cfallback \u5230 process.kill");
|
|
1337
|
+
}
|
|
1313
1338
|
const pattern = `${target.host}:${target.port}`;
|
|
1314
1339
|
const results = await stopInstances(pattern, { registry });
|
|
1315
1340
|
const r = results.find((x) => x.instance.instanceId === id);
|
|
@@ -1325,6 +1350,19 @@ data: ${JSON.stringify({ instances: items })}
|
|
|
1325
1350
|
res.status(e.httpStatus).json({ error: e.toPayload() });
|
|
1326
1351
|
}
|
|
1327
1352
|
});
|
|
1353
|
+
if (opts.selfShutdown) {
|
|
1354
|
+
const triggerSelfShutdown = opts.selfShutdown;
|
|
1355
|
+
router.post("/instances/self/shutdown", (req, res) => {
|
|
1356
|
+
const token = typeof req.query["token"] === "string" ? req.query["token"] : "";
|
|
1357
|
+
if (!token || !authModule.verifyToken(token)) {
|
|
1358
|
+
res.status(401).json({ error: { code: ErrorCode.AUTH_INVALID_TOKEN, message: "invalid token" } });
|
|
1359
|
+
return;
|
|
1360
|
+
}
|
|
1361
|
+
logger.info({ ip: req.ip }, "POST /instances/self/shutdown");
|
|
1362
|
+
res.json({ ok: true });
|
|
1363
|
+
setImmediate(() => triggerSelfShutdown());
|
|
1364
|
+
});
|
|
1365
|
+
}
|
|
1328
1366
|
return router;
|
|
1329
1367
|
}
|
|
1330
1368
|
var init_instance_routes = __esm({
|
|
@@ -1470,7 +1508,7 @@ function detectDisplayIp(hostHint) {
|
|
|
1470
1508
|
}
|
|
1471
1509
|
}
|
|
1472
1510
|
const picked = tailscale[0] ?? lanReal[0] ?? lanVirtual[0] ?? linkLocals[0] ?? "127.0.0.1";
|
|
1473
|
-
if (process.env["ATR_DEBUG_NETWORK"]
|
|
1511
|
+
if (process.env["ATR_DEBUG_NETWORK"] === "1") {
|
|
1474
1512
|
process.stderr.write(`[detectDisplayIp] tailscale=[${tailscale.join(",")}] lanReal=[${lanReal.join(",")}] lanVirtual=[${lanVirtual.join(",")}] linkLocal=[${linkLocals.join(",")}] \u2192 picked=${picked}
|
|
1475
1513
|
`);
|
|
1476
1514
|
}
|
|
@@ -1602,7 +1640,9 @@ function createApiRouter(opts = {}) {
|
|
|
1602
1640
|
authModule: opts.authModule,
|
|
1603
1641
|
registry: opts.registry,
|
|
1604
1642
|
currentInstanceId: opts.currentInstanceId,
|
|
1605
|
-
spawner: opts.spawner
|
|
1643
|
+
spawner: opts.spawner,
|
|
1644
|
+
selfShutdown: opts.selfShutdown,
|
|
1645
|
+
sharedToken: opts.sharedToken
|
|
1606
1646
|
}));
|
|
1607
1647
|
}
|
|
1608
1648
|
if (opts.authModule && opts.pushService) {
|
|
@@ -1715,7 +1755,7 @@ var init_pty_manager = __esm({
|
|
|
1715
1755
|
*/
|
|
1716
1756
|
write(data) {
|
|
1717
1757
|
if (!this.process) {
|
|
1718
|
-
logger.
|
|
1758
|
+
logger.debug({ dataLength: data.length }, "\u5C1D\u8BD5\u5199\u5165 PTY \u4F46\u8FDB\u7A0B\u672A\u8FD0\u884C");
|
|
1719
1759
|
return;
|
|
1720
1760
|
}
|
|
1721
1761
|
this.process.write(data);
|
|
@@ -1824,6 +1864,7 @@ var init_pty_manager = __esm({
|
|
|
1824
1864
|
if (this._inAltScreen !== isEnter) {
|
|
1825
1865
|
this._inAltScreen = isEnter;
|
|
1826
1866
|
logger.debug({ inAltScreen: isEnter }, "PTY alt-screen \u72B6\u6001\u5207\u6362");
|
|
1867
|
+
this.emit("altScreenChange", isEnter);
|
|
1827
1868
|
}
|
|
1828
1869
|
}
|
|
1829
1870
|
}
|
|
@@ -1934,7 +1975,7 @@ var init_ws_server = __esm({
|
|
|
1934
1975
|
}
|
|
1935
1976
|
const clientType = this.opts.authenticate ? this.opts.authenticate(req) : "webapp";
|
|
1936
1977
|
if (clientType === null) {
|
|
1937
|
-
logger.
|
|
1978
|
+
logger.debug({ url: req.url }, "WS upgrade \u88AB\u9274\u6743\u62D2\u7EDD");
|
|
1938
1979
|
socket.write("HTTP/1.1 401 Unauthorized\r\n\r\n");
|
|
1939
1980
|
socket.destroy();
|
|
1940
1981
|
return;
|
|
@@ -2393,6 +2434,9 @@ var init_session_controller = __esm({
|
|
|
2393
2434
|
this.pty.on("resize", (cols, rows) => {
|
|
2394
2435
|
this.ws.broadcast({ type: "terminal_resize", cols, rows });
|
|
2395
2436
|
});
|
|
2437
|
+
this.pty.on("altScreenChange", (inAltScreen) => {
|
|
2438
|
+
this.ws.broadcast({ type: "alt_screen_change", inAltScreen });
|
|
2439
|
+
});
|
|
2396
2440
|
}
|
|
2397
2441
|
/**
|
|
2398
2442
|
* 入队一段 PTY 输出,按三阈值决定是否立即 flush
|
|
@@ -2891,7 +2935,7 @@ function createWsAuthenticate(authModule) {
|
|
|
2891
2935
|
logger.info({ remoteAddress: req.socket.remoteAddress }, "WS \u901A\u8FC7 URL token \u8BA4\u8BC1\uFF08attach\uFF09");
|
|
2892
2936
|
return "attach";
|
|
2893
2937
|
}
|
|
2894
|
-
logger.
|
|
2938
|
+
logger.debug({ remoteAddress: req.socket.remoteAddress }, "WS URL token \u65E0\u6548");
|
|
2895
2939
|
return null;
|
|
2896
2940
|
}
|
|
2897
2941
|
const cookieHeader = req.headers.cookie ?? "";
|
|
@@ -2899,7 +2943,7 @@ function createWsAuthenticate(authModule) {
|
|
|
2899
2943
|
if (sid && authModule.validateSession(sid)) {
|
|
2900
2944
|
return "webapp";
|
|
2901
2945
|
}
|
|
2902
|
-
logger.
|
|
2946
|
+
logger.debug({
|
|
2903
2947
|
remoteAddress: req.socket.remoteAddress,
|
|
2904
2948
|
cookieNames: cookieHeader.split(";").map((c) => c.trim().split("=")[0]).filter(Boolean),
|
|
2905
2949
|
expectedCookie: authModule.getCookieName()
|
|
@@ -2958,6 +3002,7 @@ var init_hook_receiver = __esm({
|
|
|
2958
3002
|
import { existsSync as existsSync3, readFileSync as readFileSync3, writeFileSync as writeFileSync3, mkdirSync as mkdirSync4, copyFileSync } from "node:fs";
|
|
2959
3003
|
import { resolve as resolve4, basename as basename2 } from "node:path";
|
|
2960
3004
|
import { homedir as homedir2 } from "node:os";
|
|
3005
|
+
import { statSync as statSync2 } from "node:fs";
|
|
2961
3006
|
function createClaudeSettings(port, existing) {
|
|
2962
3007
|
const hookUrl = `http://127.0.0.1:${port}/api/hook`;
|
|
2963
3008
|
const hookCommand = `curl -s -X POST ${hookUrl} -H 'Content-Type: application/json' -d @-`;
|
|
@@ -3194,7 +3239,26 @@ function resolveDefaultShell(env) {
|
|
|
3194
3239
|
const shell = env["SHELL"];
|
|
3195
3240
|
if (shell && shell.length > 0)
|
|
3196
3241
|
return shell;
|
|
3197
|
-
|
|
3242
|
+
if (process.platform === "win32") {
|
|
3243
|
+
return resolveWindowsShell();
|
|
3244
|
+
}
|
|
3245
|
+
return "/bin/sh";
|
|
3246
|
+
}
|
|
3247
|
+
function resolveWindowsShell() {
|
|
3248
|
+
const candidates = ["pwsh.exe", "powershell.exe", "cmd.exe"];
|
|
3249
|
+
const pathDirs = (process.env["PATH"] ?? "").split(";").filter(Boolean);
|
|
3250
|
+
for (const cmd of candidates) {
|
|
3251
|
+
for (const dir of pathDirs) {
|
|
3252
|
+
try {
|
|
3253
|
+
const full = `${dir}\\${cmd}`;
|
|
3254
|
+
if (statSync2(full).isFile()) {
|
|
3255
|
+
return cmd;
|
|
3256
|
+
}
|
|
3257
|
+
} catch {
|
|
3258
|
+
}
|
|
3259
|
+
}
|
|
3260
|
+
}
|
|
3261
|
+
return "cmd.exe";
|
|
3198
3262
|
}
|
|
3199
3263
|
function defaultShellArgs(command) {
|
|
3200
3264
|
const base = command.split("/").pop()?.toLowerCase() ?? "";
|
|
@@ -3389,7 +3453,7 @@ var init_port_finder = __esm({
|
|
|
3389
3453
|
|
|
3390
3454
|
// backend/dist/registry/instance-spawner.js
|
|
3391
3455
|
import { spawn as spawn2 } from "node:child_process";
|
|
3392
|
-
import { existsSync as existsSync5, statSync as
|
|
3456
|
+
import { existsSync as existsSync5, statSync as statSync3, openSync } from "node:fs";
|
|
3393
3457
|
import { resolve as resolve6, isAbsolute, dirname as dirname3 } from "node:path";
|
|
3394
3458
|
function waitForEarlyExit(child, timeoutMs) {
|
|
3395
3459
|
return new Promise((res) => {
|
|
@@ -3461,7 +3525,7 @@ var init_instance_spawner = __esm({
|
|
|
3461
3525
|
if (!existsSync5(cwd)) {
|
|
3462
3526
|
throw new InstanceError(ErrorCode.CWD_NOT_EXIST, `\u5DE5\u4F5C\u76EE\u5F55\u4E0D\u5B58\u5728\uFF1A${cwd}`, 400);
|
|
3463
3527
|
}
|
|
3464
|
-
if (!
|
|
3528
|
+
if (!statSync3(cwd).isDirectory()) {
|
|
3465
3529
|
throw new InstanceError(ErrorCode.CWD_NOT_EXIST, `cwd \u4E0D\u662F\u76EE\u5F55\uFF1A${cwd}`, 400);
|
|
3466
3530
|
}
|
|
3467
3531
|
const name = input.name && input.name.trim() ? input.name.trim() : basename3(cwd);
|
|
@@ -4096,6 +4160,9 @@ ${hint}
|
|
|
4096
4160
|
},
|
|
4097
4161
|
credentials: true
|
|
4098
4162
|
}));
|
|
4163
|
+
let triggerShutdown = () => {
|
|
4164
|
+
logger.warn("shutdown \u8FD8\u672A\u5C31\u7EEA\u5374\u88AB\u8C03\u7528\uFF0C\u5FFD\u7565");
|
|
4165
|
+
};
|
|
4099
4166
|
app.use("/api", createApiRouter({
|
|
4100
4167
|
authModule,
|
|
4101
4168
|
hookReceiver,
|
|
@@ -4105,7 +4172,9 @@ ${hint}
|
|
|
4105
4172
|
spawner,
|
|
4106
4173
|
pushService,
|
|
4107
4174
|
port: cfg.port,
|
|
4108
|
-
displayIp
|
|
4175
|
+
displayIp,
|
|
4176
|
+
selfShutdown: () => triggerShutdown(),
|
|
4177
|
+
sharedToken: cfg.token
|
|
4109
4178
|
}));
|
|
4110
4179
|
let devProxy = null;
|
|
4111
4180
|
if (cfg.devProxyPort !== void 0) {
|
|
@@ -4167,10 +4236,12 @@ ${hint}
|
|
|
4167
4236
|
relay.stop();
|
|
4168
4237
|
pty2.destroy();
|
|
4169
4238
|
if (process.stdout.isTTY) {
|
|
4170
|
-
process.stdout.write("\x1B[?
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4239
|
+
process.stdout.write("\x1B[?1004l\x1B[?25h\x1B[!p\x1B[0m");
|
|
4240
|
+
if (process.platform !== "win32") {
|
|
4241
|
+
try {
|
|
4242
|
+
execSync("stty sane 2>/dev/null", { stdio: "ignore" });
|
|
4243
|
+
} catch {
|
|
4244
|
+
}
|
|
4174
4245
|
}
|
|
4175
4246
|
}
|
|
4176
4247
|
ipMonitor.stop();
|
|
@@ -4197,6 +4268,7 @@ ${hint}
|
|
|
4197
4268
|
relay.stop();
|
|
4198
4269
|
ctrl.setStatus("idle", err.message);
|
|
4199
4270
|
});
|
|
4271
|
+
triggerShutdown = () => shutdown(0);
|
|
4200
4272
|
process.on("SIGINT", () => shutdown(0));
|
|
4201
4273
|
process.on("SIGTERM", () => shutdown(0));
|
|
4202
4274
|
httpServer.on("error", (err) => {
|
|
@@ -4679,11 +4751,11 @@ var init_attach_client = __esm({
|
|
|
4679
4751
|
try {
|
|
4680
4752
|
parsed = JSON.parse(raw.toString());
|
|
4681
4753
|
} catch {
|
|
4682
|
-
logger.
|
|
4754
|
+
logger.debug("\u6536\u5230\u975E JSON WS \u6D88\u606F\uFF0C\u5FFD\u7565");
|
|
4683
4755
|
return;
|
|
4684
4756
|
}
|
|
4685
4757
|
if (!isServerMessage(parsed)) {
|
|
4686
|
-
logger.
|
|
4758
|
+
logger.debug("\u6536\u5230\u4E0D\u8BC6\u522B\u7684 server message\uFF0C\u5FFD\u7565");
|
|
4687
4759
|
return;
|
|
4688
4760
|
}
|
|
4689
4761
|
this.handleServerMessage(parsed);
|