tmuxes 0.1.0
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/LICENSE +21 -0
- package/README.md +213 -0
- package/bin/tmuxes.js +40 -0
- package/dist/config.js +31 -0
- package/dist/config.js.map +1 -0
- package/dist/exe.js +37 -0
- package/dist/exe.js.map +1 -0
- package/dist/exec.js +43 -0
- package/dist/exec.js.map +1 -0
- package/dist/files.js +250 -0
- package/dist/files.js.map +1 -0
- package/dist/foldersStore.js +101 -0
- package/dist/foldersStore.js.map +1 -0
- package/dist/index.js +73 -0
- package/dist/index.js.map +1 -0
- package/dist/logger.js +16 -0
- package/dist/logger.js.map +1 -0
- package/dist/openBrowser.js +31 -0
- package/dist/openBrowser.js.map +1 -0
- package/dist/platform.js +5 -0
- package/dist/platform.js.map +1 -0
- package/dist/rest/router.js +178 -0
- package/dist/rest/router.js.map +1 -0
- package/dist/targets.js +131 -0
- package/dist/targets.js.map +1 -0
- package/dist/tmux/builder.js +128 -0
- package/dist/tmux/builder.js.map +1 -0
- package/dist/tmux/formats.js +55 -0
- package/dist/tmux/formats.js.map +1 -0
- package/dist/tmux/sessions.js +99 -0
- package/dist/tmux/sessions.js.map +1 -0
- package/dist/validate.js +65 -0
- package/dist/validate.js.map +1 -0
- package/dist/winshell/manager.js +258 -0
- package/dist/winshell/manager.js.map +1 -0
- package/dist/ws/protocol.js +4 -0
- package/dist/ws/protocol.js.map +1 -0
- package/dist/ws/sshState.js +35 -0
- package/dist/ws/sshState.js.map +1 -0
- package/dist/ws/terminalSession.js +204 -0
- package/dist/ws/terminalSession.js.map +1 -0
- package/dist/ws/wsServer.js +151 -0
- package/dist/ws/wsServer.js.map +1 -0
- package/dist/wsl.js +35 -0
- package/dist/wsl.js.map +1 -0
- package/package.json +61 -0
- package/public/assets/index-CfimUdwq.js +44 -0
- package/public/assets/index-DeKxLCiY.css +1 -0
- package/public/index.html +13 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 tmuxes contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
3
|
+
# 🖥️ tmuxes
|
|
4
|
+
|
|
5
|
+
**简体中文** | [English](./README.en.md)
|
|
6
|
+
|
|
7
|
+
### 一个浏览器标签页,掌控一整群 CLI coding agent。
|
|
8
|
+
|
|
9
|
+
**Claude Code · Codex · OpenCode · Hermes** —— 每个 agent 独占一个 tmux 会话,
|
|
10
|
+
横跨 **本地 · SSH · WSL**,还自带每个 agent 工作目录的文件浏览器。
|
|
11
|
+
|
|
12
|
+
<p>
|
|
13
|
+
<img alt="platform" src="https://img.shields.io/badge/platform-Linux%20%7C%20macOS%20%7C%20Windows%2011-2b2b2b?style=flat-square">
|
|
14
|
+
<img alt="React" src="https://img.shields.io/badge/React-19-61DAFB?style=flat-square&logo=react&logoColor=black">
|
|
15
|
+
<img alt="TypeScript" src="https://img.shields.io/badge/TypeScript-5-3178C6?style=flat-square&logo=typescript&logoColor=white">
|
|
16
|
+
<img alt="Node.js" src="https://img.shields.io/badge/Node.js-22-339933?style=flat-square&logo=nodedotjs&logoColor=white">
|
|
17
|
+
<img alt="Vite" src="https://img.shields.io/badge/Vite-8-646CFF?style=flat-square&logo=vite&logoColor=white">
|
|
18
|
+
<img alt="tmux" src="https://img.shields.io/badge/tmux-3.x-1BB91F?style=flat-square&logo=tmux&logoColor=white">
|
|
19
|
+
<img alt="xterm.js" src="https://img.shields.io/badge/xterm.js-6-1f6feb?style=flat-square">
|
|
20
|
+
</p>
|
|
21
|
+
|
|
22
|
+
<sub>🔒 仅本机 · ⚡ 一键启动 · 🪟 Windows 上直通 WSL · 🧩 零配置</sub>
|
|
23
|
+
|
|
24
|
+
</div>
|
|
25
|
+
|
|
26
|
+
---
|
|
27
|
+
|
|
28
|
+
> **为什么做这个?** 现代编码 agent 都是常驻的终端进程。同时跑好几个,你就开始在一堆窗格、SSH 窗口之间手忙脚乱:
|
|
29
|
+
> 「等等,刚那个是在哪台机器上来着?」**tmuxes** 把它们全塞进一个清爽的网页 UI:
|
|
30
|
+
> 开一个会话、丢进文件夹、看它干活、顺手瞄一眼它正在改的文件 —— 本地还是远程,都是同一个视图。
|
|
31
|
+
|
|
32
|
+
## ✨ 特性亮点
|
|
33
|
+
|
|
34
|
+
| | |
|
|
35
|
+
|---|---|
|
|
36
|
+
| 🧠 **为 agent 而生** | 每个 agent 独占一个 tmux 会话。新建时可带初始命令(比如 `claude` 或 `codex`),选中后右侧就是一个**完全可交互的实时终端**。 |
|
|
37
|
+
| 🌐 **本地 · SSH · WSL · 原生 Windows** | 一个侧边栏同时列出你的本机、`~/.ssh/config` 里的主机、(Windows 上)你的 WSL 发行版,以及(Windows)原生 PowerShell / cmd 会话 —— 全部并排排开。 |
|
|
38
|
+
| 🗂️ **文件夹树** | 像资源管理器一样,把会话拖进**可拖拽的文件夹**里整理。按目标分别持久化到本地。 |
|
|
39
|
+
| 📂 **实时文件浏览 + 编辑** | 侧边栏底部跟随每个会话的**工作目录** —— 点一个代码文件就能把终端一分为二,在下面**直接读和改**(可保存、撤销/重做)。 |
|
|
40
|
+
| 🔁 **真·多端同步** | 基于原生 `tmux attach`:同一个会话开两个标签页,逐键同步、互为镜像。 |
|
|
41
|
+
| ⚙️ **可调** | 侧边栏、终端、文件查看器的字号都能调,**实时生效**、刷新后仍保留。 |
|
|
42
|
+
| 🚀 **一键启动** | 双击 `start.cmd` / `start.command` / `start.sh` → 自动构建、启动、打开浏览器。 |
|
|
43
|
+
|
|
44
|
+
## 🖼️ 长这样
|
|
45
|
+
|
|
46
|
+
```text
|
|
47
|
+
┌───────────────────────────┬──────────────────────────────────────────────┐
|
|
48
|
+
│ tmuxes ⟳ │ ● claude-code — agent1 │
|
|
49
|
+
│ │ │
|
|
50
|
+
│ ▾ Local local │ $ claude "refactor the auth module" │
|
|
51
|
+
│ 📂 frontend │ ⠿ thinking… │
|
|
52
|
+
│ ● agent1 2 win · 3m │ ▸ Editing src/auth/session.ts │
|
|
53
|
+
│ ○ agent2 1 win │ ▸ Running tests… │
|
|
54
|
+
│ 📁 backend │ │
|
|
55
|
+
│ ○ scratch 1 win │ │
|
|
56
|
+
│ ▾ devbox ssh ├───────────────── src/auth/session.ts ─────────┤
|
|
57
|
+
│ ○ build 1 win │ 1 export function createSession(user) { │
|
|
58
|
+
│ ─────────── drag ──────── │ 2 const token = sign(user, KEY) │
|
|
59
|
+
│ WORKING DIRECTORY ↑ ⌂ │ 3 return { token, exp: Date.now() + TTL } │
|
|
60
|
+
│ 📁 src │ 4 } │
|
|
61
|
+
│ 📄 session.ts │ │
|
|
62
|
+
│ 📄 README.md │ │
|
|
63
|
+
│ ⚙ Settings │ │
|
|
64
|
+
└───────────────────────────┴──────────────────────────────────────────────┘
|
|
65
|
+
侧边栏:tmux 树 + 当前目录文件浏览器 终端 ╱ 打开的文件
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
## 🏗️ 架构
|
|
69
|
+
|
|
70
|
+
```text
|
|
71
|
+
REST (create · list · rename · kill · cwd · files)
|
|
72
|
+
┌────────────┐ ┌──────────────────────┐ ┌──────────────────────────────────┐
|
|
73
|
+
│ Browser │──▶│ Node · Express · ws │──pty──▶│ tmux (Linux/macOS)│
|
|
74
|
+
│ xterm.js │◀──│ node-pty │──pty──▶│ ssh -tt user@host → tmux (remote)│
|
|
75
|
+
└────────────┘ └──────────────────────┘──pty──▶│ wsl.exe -d <distro> → tmux (Windows)│
|
|
76
|
+
▲ binary bytes ⇄ WebSocket ⇄ JSON control └──────────────────────────────────┘
|
|
77
|
+
```
|
|
78
|
+
|
|
79
|
+
- **`client/`** —— React + Vite + TypeScript,终端基于 [`@xterm/xterm`](https://www.npmjs.com/package/@xterm/xterm)。
|
|
80
|
+
- **`server/`** —— Node + Express + `ws` + `node-pty`。一个小型 REST API 跑短命的 tmux *管理*命令;单个 WebSocket 端点负责交互式 *attach* 的流式传输。
|
|
81
|
+
|
|
82
|
+
> **Windows 上没有原生 tmux?** 没问题。服务端原生运行(node-pty 用 ConPTY),通过 `wsl.exe` 直通你 WSL 发行版里的 tmux。Linux/macOS 上则直接和本地 tmux 通信。远程主机用系统 `ssh` 二进制,复用你已有的 `~/.ssh` 密钥 / `ssh-agent` —— **绝不存储任何密码。**
|
|
83
|
+
|
|
84
|
+
## 📦 用 npm 安装
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
# 一键运行(无需克隆,自动开浏览器):
|
|
88
|
+
npx tmuxes
|
|
89
|
+
|
|
90
|
+
# 或全局安装后用 tmuxes 命令:
|
|
91
|
+
npm install -g tmuxes
|
|
92
|
+
tmuxes # → http://127.0.0.1:7420
|
|
93
|
+
|
|
94
|
+
# 常用参数:
|
|
95
|
+
tmuxes --port 8080 --no-open
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
> 前提:你要连的机器/主机上装了 **tmux**。**Linux** 上 `node-pty` 需现场编译(装 `build-essential` + `python3`);**Windows / macOS** 有预编译二进制,真·一键。详见下方「环境要求」。
|
|
99
|
+
|
|
100
|
+
## 🚀 从源码一键启动(开发用)
|
|
101
|
+
|
|
102
|
+
<table>
|
|
103
|
+
<tr><th>系统</th><th>怎么做</th></tr>
|
|
104
|
+
<tr><td><b>🪟 Windows 11</b></td><td>双击 <b><code>start.cmd</code></b>(或在 Windows Terminal 里运行)。自动装依赖、构建、启动服务,并打开 <code>http://127.0.0.1:7420</code>。你的 WSL 发行版会出现在侧边栏。</td></tr>
|
|
105
|
+
<tr><td><b>🍎 macOS</b></td><td>在访达里双击 <b><code>start.command</code></b> <sub>(首次:右键 → 打开,绕过 Gatekeeper)</sub>。</td></tr>
|
|
106
|
+
<tr><td><b>🐧 Linux</b></td><td>运行 <b><code>./start.sh</code></b>。</td></tr>
|
|
107
|
+
</table>
|
|
108
|
+
|
|
109
|
+
## 🔧 手动运行
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
npm install # node-pty:Win/macOS 有预编译,Linux 从源码编译
|
|
113
|
+
|
|
114
|
+
# 开发 —— Vite 开发服务器 + API,带热更新:
|
|
115
|
+
npm run dev # → http://localhost:5173
|
|
116
|
+
|
|
117
|
+
# 生产 —— 构建客户端,由单进程统一服务:
|
|
118
|
+
npm run build
|
|
119
|
+
npm start # → http://localhost:7420 (设 TMUXES_OPEN=1 可自动打开浏览器)
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## 🧩 目标(Targets)
|
|
123
|
+
|
|
124
|
+
- **本地** *(Linux/macOS)* —— 你机器上的 tmux。Windows 上不显示。
|
|
125
|
+
- **Windows 本机终端** *(Windows)* —— 服务端用 ConPTY 直接开 PowerShell / cmd 等本机 shell(自动探测 `pwsh` → `powershell` → `cmd` → Git Bash),新建时可在下拉里选 shell。会话随**服务端进程**存活(刷新 / 重连 / 多标签都不丢,重启服务端会丢);这类会话没有 tmux 工作目录,故隐藏底部文件浏览器。
|
|
126
|
+
- **WSL 发行版** *(Windows)* —— 通过 `wsl.exe -l -q` 自动发现;每个发行版一个目标。发行版里必须装了 tmux。
|
|
127
|
+
- **SSH 主机** —— 从你的 `~/.ssh/config` 的 `Host` 条目里发现(跳过通配符)。也可显式添加:
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
TMUXES_HOSTS="alice@web1,bob@db2:2222" npm run dev # Linux / macOS
|
|
131
|
+
set TMUXES_HOSTS=alice@web1,bob@db2:2222 && npm run dev # Windows cmd
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
密钥 / agent 认证必须在普通 shell 里已经能用。全新主机请先在普通终端里接受一次它的 host key。
|
|
135
|
+
|
|
136
|
+
## 💻 环境要求
|
|
137
|
+
|
|
138
|
+
所有平台都需要 **Node 18+**(开发用的是 22)和 **npm**。其余:
|
|
139
|
+
|
|
140
|
+
<details>
|
|
141
|
+
<summary><b>🪟 Windows 11</b></summary>
|
|
142
|
+
|
|
143
|
+
- WSL2 且至少一个发行版,并在其中装好 **tmux**(`sudo apt install tmux`)。
|
|
144
|
+
- 内置的 OpenSSH 客户端覆盖 SSH 目标。
|
|
145
|
+
- node-pty 提供**预编译 Windows 二进制** —— 不需要编译器。
|
|
146
|
+
|
|
147
|
+
</details>
|
|
148
|
+
|
|
149
|
+
<details>
|
|
150
|
+
<summary><b>🍎 macOS</b></summary>
|
|
151
|
+
|
|
152
|
+
- `tmux` 在 `PATH` 上(`brew install tmux`)。
|
|
153
|
+
- node-pty 提供**预编译 darwin 二进制**。
|
|
154
|
+
- 从访达启动却找不到 tmux?确保 Homebrew 的 bin 目录在 GUI 的 `PATH` 里。
|
|
155
|
+
|
|
156
|
+
</details>
|
|
157
|
+
|
|
158
|
+
<details>
|
|
159
|
+
<summary><b>🐧 Linux</b></summary>
|
|
160
|
+
|
|
161
|
+
- `tmux`,外加给 node-pty 的 C/C++ 工具链 + Python 3(**没有 Linux 预编译 —— 安装时现场编译**):
|
|
162
|
+
```bash
|
|
163
|
+
sudo apt-get install -y build-essential python3 tmux
|
|
164
|
+
```
|
|
165
|
+
- WSL 小坑:`node-gyp` 会用 `PATH` 上的任意 `python3`。如果坏掉的 conda Python 把构建搞挂了:
|
|
166
|
+
```bash
|
|
167
|
+
npm config set python /usr/bin/python3
|
|
168
|
+
```
|
|
169
|
+
|
|
170
|
+
</details>
|
|
171
|
+
|
|
172
|
+
<details>
|
|
173
|
+
<summary><b>(备选)把服务端跑在 WSL 内部</b></summary>
|
|
174
|
+
|
|
175
|
+
在 Windows 上,你也可以把整个服务端跑在 WSL **内部**(像 Linux 一样),然后在 Windows 上开浏览器 —— WSL2 会转发 `localhost`。一键启动脚本用的是「原生 Windows + `wsl.exe`」方案,这样能在一个地方同时覆盖 SSH 目标和多个发行版。
|
|
176
|
+
|
|
177
|
+
</details>
|
|
178
|
+
|
|
179
|
+
## 🔒 安全
|
|
180
|
+
|
|
181
|
+
> ⚠️ **tmuxes 会把完整的 shell 访问权交给任何能连上它的人。** 它是一个单用户、仅本机的开发工具。
|
|
182
|
+
|
|
183
|
+
设计上它:
|
|
184
|
+
|
|
185
|
+
- 只绑定 **`127.0.0.1`** —— 绑定地址在运行时不可配置,
|
|
186
|
+
- **没有任何认证**,
|
|
187
|
+
- **从不起 shell**(argv 数组 + `shell:false`),并对每个输入做白名单校验,
|
|
188
|
+
- 拒绝 `Origin` 非 localhost 的 WebSocket 升级(防 DNS-rebind),
|
|
189
|
+
- 把文件浏览器 / 编辑器限制在所选 tmux 会话的**当前工作目录**之内。
|
|
190
|
+
|
|
191
|
+
**请勿**对它做反向代理、隧道、端口转发,或暴露到 `0.0.0.0`。机器上任何本地用户都能用它。
|
|
192
|
+
|
|
193
|
+
## 🧪 测试
|
|
194
|
+
|
|
195
|
+
```bash
|
|
196
|
+
npm test # vitest:输入校验、列表解析、ssh/tmux/wsl 的 argv 形状
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
<div align="center">
|
|
200
|
+
<sub>用 React、TypeScript、node-pty & xterm.js 打造 —— 外加大量 tmux。盯娃愉快。🤖</sub>
|
|
201
|
+
</div>
|
|
202
|
+
|
|
203
|
+
## 🧑🔬 关于作者
|
|
204
|
+
|
|
205
|
+
> 嗨,我是这个项目的作者 👋
|
|
206
|
+
>
|
|
207
|
+
> 中国科学技术大学(USTC)理论物理在读博士,白天的日常是和**多体场论可解释的费米超流理论**(这玩意是用来研究和解释高温超导的),还有一大坨**高性能数值计算**代码贴身肉搏 ⚛️。
|
|
208
|
+
>
|
|
209
|
+
> 这个小工具其实是被一堆 agent 终端搞到头大之后的「自救产物」—— 既然每天都要盯一群 CLI agent 干活,那干脆给它们造个顺手的指挥台 😎。
|
|
210
|
+
>
|
|
211
|
+
> 如果你也对这些感兴趣(物理也好、代码也好),或者想一起折腾这个开源项目,随时来找我玩 📮
|
|
212
|
+
>
|
|
213
|
+
> **📧 junruwu@mail.ustc.edu.cn**
|
package/bin/tmuxes.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// tmuxes — web UI to manage tmux sessions (local / SSH / WSL).
|
|
3
|
+
// Parses a couple of flags, sets the env the server reads, then launches it.
|
|
4
|
+
|
|
5
|
+
const args = process.argv.slice(2);
|
|
6
|
+
|
|
7
|
+
if (args.includes('--help') || args.includes('-h')) {
|
|
8
|
+
console.log(`tmuxes — one browser tab to run and supervise tmux sessions (local · SSH · WSL)
|
|
9
|
+
|
|
10
|
+
Usage:
|
|
11
|
+
tmuxes [options]
|
|
12
|
+
|
|
13
|
+
Options:
|
|
14
|
+
--port <n> Port to listen on (default 7420, env TMUXES_PORT)
|
|
15
|
+
--host <addr> Bind address (default 127.0.0.1, env TMUXES_HOST)
|
|
16
|
+
--no-open Do not open the browser
|
|
17
|
+
-h, --help Show this help
|
|
18
|
+
|
|
19
|
+
Then open http://127.0.0.1:7420 (opens automatically unless --no-open).
|
|
20
|
+
Requires tmux installed on the machine/host you connect to.`);
|
|
21
|
+
process.exit(0);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function flag(name) {
|
|
25
|
+
const i = args.indexOf(name);
|
|
26
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const port = flag('--port');
|
|
30
|
+
if (port) process.env.TMUXES_PORT = port;
|
|
31
|
+
const host = flag('--host');
|
|
32
|
+
if (host) process.env.TMUXES_HOST = host;
|
|
33
|
+
|
|
34
|
+
// Open the browser by default (the "one-click run" experience), unless the
|
|
35
|
+
// user opted out or already set TMUXES_OPEN.
|
|
36
|
+
if (!args.includes('--no-open') && process.env.TMUXES_OPEN === undefined) {
|
|
37
|
+
process.env.TMUXES_OPEN = '1';
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await import('../dist/index.js');
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/** Central configuration. Localhost-only by design — see README/SECURITY. */
|
|
2
|
+
export const config = {
|
|
3
|
+
/** Bind address. Intentionally not configurable: this is a no-auth local shell UI. */
|
|
4
|
+
host: '127.0.0.1',
|
|
5
|
+
port: Number(process.env.TMUXES_PORT ?? 7420),
|
|
6
|
+
/** ssh timeouts (seconds). */
|
|
7
|
+
ssh: {
|
|
8
|
+
/** Management calls fail fast so the UI never hangs. */
|
|
9
|
+
connectTimeoutMgmt: 8,
|
|
10
|
+
/** Interactive attach is allowed a little longer. */
|
|
11
|
+
connectTimeoutTty: 10,
|
|
12
|
+
serverAliveInterval: 30,
|
|
13
|
+
},
|
|
14
|
+
/**
|
|
15
|
+
* Allowed WebSocket Origins. The WS upgrade bypasses Express middleware, so
|
|
16
|
+
* we enforce this in the upgrade handler to block DNS-rebind / cross-site WS
|
|
17
|
+
* hijack. Empty/absent Origin (non-browser clients) is allowed.
|
|
18
|
+
*/
|
|
19
|
+
isAllowedOrigin(origin) {
|
|
20
|
+
if (!origin)
|
|
21
|
+
return true;
|
|
22
|
+
try {
|
|
23
|
+
const { hostname } = new URL(origin);
|
|
24
|
+
return hostname === '127.0.0.1' || hostname === 'localhost' || hostname === '[::1]';
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"config.js","sourceRoot":"","sources":["../src/config.ts"],"names":[],"mappings":"AAAA,6EAA6E;AAC7E,MAAM,CAAC,MAAM,MAAM,GAAG;IACpB,sFAAsF;IACtF,IAAI,EAAE,WAAW;IACjB,IAAI,EAAE,MAAM,CAAC,OAAO,CAAC,GAAG,CAAC,WAAW,IAAI,IAAI,CAAC;IAE7C,8BAA8B;IAC9B,GAAG,EAAE;QACH,wDAAwD;QACxD,kBAAkB,EAAE,CAAC;QACrB,qDAAqD;QACrD,iBAAiB,EAAE,EAAE;QACrB,mBAAmB,EAAE,EAAE;KACxB;IAED;;;;OAIG;IACH,eAAe,CAAC,MAA0B;QACxC,IAAI,CAAC,MAAM;YAAE,OAAO,IAAI,CAAC;QACzB,IAAI,CAAC;YACH,MAAM,EAAE,QAAQ,EAAE,GAAG,IAAI,GAAG,CAAC,MAAM,CAAC,CAAC;YACrC,OAAO,QAAQ,KAAK,WAAW,IAAI,QAAQ,KAAK,WAAW,IAAI,QAAQ,KAAK,OAAO,CAAC;QACtF,CAAC;QAAC,MAAM,CAAC;YACP,OAAO,KAAK,CAAC;QACf,CAAC;IACH,CAAC;CACO,CAAC"}
|
package/dist/exe.js
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs';
|
|
2
|
+
import { isAbsolute, join, delimiter } from 'node:path';
|
|
3
|
+
import { isWindows } from './platform.js';
|
|
4
|
+
/**
|
|
5
|
+
* Resolve a command name to a full path for node-pty on Windows.
|
|
6
|
+
*
|
|
7
|
+
* Unlike child_process.spawn, node-pty's ConPTY path does NOT search PATH or
|
|
8
|
+
* append a PATHEXT extension, so `pty.spawn('ssh', …)` fails with
|
|
9
|
+
* "File not found". We resolve the executable ourselves (PATH + common system
|
|
10
|
+
* locations). On POSIX, execvp already searches PATH, so the name is returned
|
|
11
|
+
* unchanged.
|
|
12
|
+
*/
|
|
13
|
+
export function resolveExecutable(file) {
|
|
14
|
+
if (!isWindows)
|
|
15
|
+
return file;
|
|
16
|
+
if (isAbsolute(file) && existsSync(file))
|
|
17
|
+
return file;
|
|
18
|
+
const hasExt = /\.[A-Za-z0-9]+$/.test(file);
|
|
19
|
+
const exts = hasExt ? [''] : (process.env.PATHEXT || '.EXE;.CMD;.BAT;.COM').split(';');
|
|
20
|
+
const sysRoot = process.env.SystemRoot || process.env.windir || 'C:\\Windows';
|
|
21
|
+
const dirs = [
|
|
22
|
+
...(process.env.PATH || '').split(delimiter),
|
|
23
|
+
join(sysRoot, 'System32'),
|
|
24
|
+
join(sysRoot, 'System32', 'OpenSSH'), // ssh.exe ships here, not always on PATH
|
|
25
|
+
];
|
|
26
|
+
for (const dir of dirs) {
|
|
27
|
+
if (!dir)
|
|
28
|
+
continue;
|
|
29
|
+
for (const ext of exts) {
|
|
30
|
+
const candidate = join(dir, file + ext);
|
|
31
|
+
if (existsSync(candidate))
|
|
32
|
+
return candidate;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return file; // fall back; node-pty surfaces a clear error if truly missing
|
|
36
|
+
}
|
|
37
|
+
//# sourceMappingURL=exe.js.map
|
package/dist/exe.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exe.js","sourceRoot":"","sources":["../src/exe.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,UAAU,EAAE,MAAM,SAAS,CAAC;AACrC,OAAO,EAAE,UAAU,EAAE,IAAI,EAAE,SAAS,EAAE,MAAM,WAAW,CAAC;AACxD,OAAO,EAAE,SAAS,EAAE,MAAM,eAAe,CAAC;AAE1C;;;;;;;;GAQG;AACH,MAAM,UAAU,iBAAiB,CAAC,IAAY;IAC5C,IAAI,CAAC,SAAS;QAAE,OAAO,IAAI,CAAC;IAC5B,IAAI,UAAU,CAAC,IAAI,CAAC,IAAI,UAAU,CAAC,IAAI,CAAC;QAAE,OAAO,IAAI,CAAC;IAEtD,MAAM,MAAM,GAAG,iBAAiB,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IAC5C,MAAM,IAAI,GAAG,MAAM,CAAC,CAAC,CAAC,CAAC,EAAE,CAAC,CAAC,CAAC,CAAC,CAAC,OAAO,CAAC,GAAG,CAAC,OAAO,IAAI,qBAAqB,CAAC,CAAC,KAAK,CAAC,GAAG,CAAC,CAAC;IACvF,MAAM,OAAO,GAAG,OAAO,CAAC,GAAG,CAAC,UAAU,IAAI,OAAO,CAAC,GAAG,CAAC,MAAM,IAAI,aAAa,CAAC;IAC9E,MAAM,IAAI,GAAG;QACX,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,IAAI,IAAI,EAAE,CAAC,CAAC,KAAK,CAAC,SAAS,CAAC;QAC5C,IAAI,CAAC,OAAO,EAAE,UAAU,CAAC;QACzB,IAAI,CAAC,OAAO,EAAE,UAAU,EAAE,SAAS,CAAC,EAAE,yCAAyC;KAChF,CAAC;IACF,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;QACvB,IAAI,CAAC,GAAG;YAAE,SAAS;QACnB,KAAK,MAAM,GAAG,IAAI,IAAI,EAAE,CAAC;YACvB,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,IAAI,GAAG,GAAG,CAAC,CAAC;YACxC,IAAI,UAAU,CAAC,SAAS,CAAC;gBAAE,OAAO,SAAS,CAAC;QAC9C,CAAC;IACH,CAAC;IACD,OAAO,IAAI,CAAC,CAAC,8DAA8D;AAC7E,CAAC"}
|
package/dist/exec.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
/**
|
|
3
|
+
* Run a command as an argv array with NO shell. Never rejects — a nonzero exit
|
|
4
|
+
* or spawn error resolves with the captured output so callers can interpret it
|
|
5
|
+
* (e.g. tmux "no server running" is a normal empty case, not a throw).
|
|
6
|
+
*/
|
|
7
|
+
export function runCommand(file, args, opts = {}) {
|
|
8
|
+
const encoding = opts.encoding ?? 'utf8';
|
|
9
|
+
return new Promise((resolve) => {
|
|
10
|
+
const child = spawn(file, args, { shell: false });
|
|
11
|
+
const outChunks = [];
|
|
12
|
+
const errChunks = [];
|
|
13
|
+
let settled = false;
|
|
14
|
+
const timer = opts.timeoutMs
|
|
15
|
+
? setTimeout(() => {
|
|
16
|
+
child.kill('SIGKILL');
|
|
17
|
+
}, opts.timeoutMs)
|
|
18
|
+
: null;
|
|
19
|
+
child.stdout.on('data', (d) => outChunks.push(d));
|
|
20
|
+
child.stderr.on('data', (d) => errChunks.push(d));
|
|
21
|
+
if (opts.input !== undefined) {
|
|
22
|
+
child.stdin.on('error', () => {
|
|
23
|
+
/* ignore EPIPE if the child exits early */
|
|
24
|
+
});
|
|
25
|
+
child.stdin.end(opts.input);
|
|
26
|
+
}
|
|
27
|
+
const done = (code, signal, extraErr) => {
|
|
28
|
+
if (settled)
|
|
29
|
+
return;
|
|
30
|
+
settled = true;
|
|
31
|
+
if (timer)
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
const stderr = Buffer.concat(errChunks).toString(encoding) + (extraErr ?? '');
|
|
34
|
+
resolve({ code, signal, stdout: Buffer.concat(outChunks).toString(encoding), stderr });
|
|
35
|
+
};
|
|
36
|
+
child.on('error', (err) => {
|
|
37
|
+
// e.g. ENOENT when ssh/tmux/wsl.exe is missing — surface as a failed result.
|
|
38
|
+
done(null, null, err.message);
|
|
39
|
+
});
|
|
40
|
+
child.on('close', (code, signal) => done(code, signal));
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
//# sourceMappingURL=exec.js.map
|
package/dist/exec.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"exec.js","sourceRoot":"","sources":["../src/exec.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,MAAM,oBAAoB,CAAC;AAiB3C;;;;GAIG;AACH,MAAM,UAAU,UAAU,CAAC,IAAY,EAAE,IAAc,EAAE,OAAmB,EAAE;IAC5E,MAAM,QAAQ,GAAG,IAAI,CAAC,QAAQ,IAAI,MAAM,CAAC;IACzC,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,KAAK,GAAG,KAAK,CAAC,IAAI,EAAE,IAAI,EAAE,EAAE,KAAK,EAAE,KAAK,EAAE,CAAC,CAAC;QAClD,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,MAAM,SAAS,GAAa,EAAE,CAAC;QAC/B,IAAI,OAAO,GAAG,KAAK,CAAC;QAEpB,MAAM,KAAK,GAAG,IAAI,CAAC,SAAS;YAC1B,CAAC,CAAC,UAAU,CAAC,GAAG,EAAE;gBACd,KAAK,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC;YACxB,CAAC,EAAE,IAAI,CAAC,SAAS,CAAC;YACpB,CAAC,CAAC,IAAI,CAAC;QAET,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAC1D,KAAK,CAAC,MAAM,CAAC,EAAE,CAAC,MAAM,EAAE,CAAC,CAAS,EAAE,EAAE,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,CAAC;QAE1D,IAAI,IAAI,CAAC,KAAK,KAAK,SAAS,EAAE,CAAC;YAC7B,KAAK,CAAC,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,GAAG,EAAE;gBAC3B,2CAA2C;YAC7C,CAAC,CAAC,CAAC;YACH,KAAK,CAAC,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;QAC9B,CAAC;QAED,MAAM,IAAI,GAAG,CAAC,IAAmB,EAAE,MAA6B,EAAE,QAAiB,EAAE,EAAE;YACrF,IAAI,OAAO;gBAAE,OAAO;YACpB,OAAO,GAAG,IAAI,CAAC;YACf,IAAI,KAAK;gBAAE,YAAY,CAAC,KAAK,CAAC,CAAC;YAC/B,MAAM,MAAM,GAAG,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,GAAG,CAAC,QAAQ,IAAI,EAAE,CAAC,CAAC;YAC9E,OAAO,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,MAAM,CAAC,MAAM,CAAC,SAAS,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,EAAE,MAAM,EAAE,CAAC,CAAC;QACzF,CAAC,CAAC;QAEF,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,GAAG,EAAE,EAAE;YACxB,6EAA6E;YAC7E,IAAI,CAAC,IAAI,EAAE,IAAI,EAAE,GAAG,CAAC,OAAO,CAAC,CAAC;QAChC,CAAC,CAAC,CAAC;QACH,KAAK,CAAC,EAAE,CAAC,OAAO,EAAE,CAAC,IAAI,EAAE,MAAM,EAAE,EAAE,CAAC,IAAI,CAAC,IAAI,EAAE,MAAM,CAAC,CAAC,CAAC;IAC1D,CAAC,CAAC,CAAC;AACL,CAAC"}
|
package/dist/files.js
ADDED
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
import { promises as fsp } from 'node:fs';
|
|
2
|
+
import { basename as posixBasename, dirname as posixDirname } from 'node:path/posix';
|
|
3
|
+
import { runCommand } from './exec.js';
|
|
4
|
+
import { commandArgv } from './tmux/builder.js';
|
|
5
|
+
import { TmuxError } from './tmux/sessions.js';
|
|
6
|
+
const REMOTE_TIMEOUT_MS = 15_000;
|
|
7
|
+
/** Max bytes returned for a file preview. */
|
|
8
|
+
export const FILE_PREVIEW_CAP = 2_000_000;
|
|
9
|
+
/** A NUL anywhere in a preview means the file is binary. */
|
|
10
|
+
const NUL = String.fromCharCode(0);
|
|
11
|
+
function timeout(t) {
|
|
12
|
+
return t.kind === 'local' ? undefined : REMOTE_TIMEOUT_MS;
|
|
13
|
+
}
|
|
14
|
+
function normalizeRoot(path) {
|
|
15
|
+
if (path === '/')
|
|
16
|
+
return path;
|
|
17
|
+
return path.replace(/\/+$/, '');
|
|
18
|
+
}
|
|
19
|
+
export function isInsideRoot(root, candidate) {
|
|
20
|
+
const normalizedRoot = normalizeRoot(root);
|
|
21
|
+
return normalizedRoot === '/' || candidate === normalizedRoot || candidate.startsWith(`${normalizedRoot}/`);
|
|
22
|
+
}
|
|
23
|
+
async function remoteRealpath(t, path) {
|
|
24
|
+
const r = await run(t, ['realpath', path]);
|
|
25
|
+
if (r.code !== 0) {
|
|
26
|
+
if (/No such file|not found/i.test(r.stderr))
|
|
27
|
+
throw new TmuxError(404, 'path not found');
|
|
28
|
+
if (/Permission denied/i.test(r.stderr))
|
|
29
|
+
throw new TmuxError(403, 'permission denied');
|
|
30
|
+
throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot resolve path');
|
|
31
|
+
}
|
|
32
|
+
const resolved = r.stdout.trim().split('\n')[0];
|
|
33
|
+
if (!resolved)
|
|
34
|
+
throw new TmuxError(502, 'empty resolved path');
|
|
35
|
+
return resolved;
|
|
36
|
+
}
|
|
37
|
+
async function realpath(t, path) {
|
|
38
|
+
if (t.kind === 'local') {
|
|
39
|
+
try {
|
|
40
|
+
return await fsp.realpath(path);
|
|
41
|
+
}
|
|
42
|
+
catch (e) {
|
|
43
|
+
throw fsError(e, path);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return remoteRealpath(t, path);
|
|
47
|
+
}
|
|
48
|
+
async function tryRealpath(t, path) {
|
|
49
|
+
try {
|
|
50
|
+
return await realpath(t, path);
|
|
51
|
+
}
|
|
52
|
+
catch (e) {
|
|
53
|
+
if (e instanceof TmuxError && e.status === 404)
|
|
54
|
+
return null;
|
|
55
|
+
throw e;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Resolve a requested target path and verify it stays inside the session's cwd.
|
|
60
|
+
* This keeps the file browser/editor scoped to the agent workspace instead of
|
|
61
|
+
* exposing arbitrary host files through the no-auth local server.
|
|
62
|
+
*/
|
|
63
|
+
export async function resolveScopedPath(t, rootPath, requestedPath, opts = {}) {
|
|
64
|
+
if (!requestedPath.startsWith('/'))
|
|
65
|
+
throw new TmuxError(400, 'path must be absolute');
|
|
66
|
+
const root = await realpath(t, rootPath);
|
|
67
|
+
let candidate = null;
|
|
68
|
+
if (opts.forWrite) {
|
|
69
|
+
candidate = await tryRealpath(t, requestedPath);
|
|
70
|
+
if (!candidate) {
|
|
71
|
+
const name = posixBasename(requestedPath);
|
|
72
|
+
if (!name || name === '.' || name === '..')
|
|
73
|
+
throw new TmuxError(400, 'invalid path');
|
|
74
|
+
const parent = await realpath(t, posixDirname(requestedPath));
|
|
75
|
+
candidate = parent === '/' ? `/${name}` : `${parent}/${name}`;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else {
|
|
79
|
+
candidate = await realpath(t, requestedPath);
|
|
80
|
+
}
|
|
81
|
+
if (!isInsideRoot(root, candidate)) {
|
|
82
|
+
throw new TmuxError(403, 'path is outside the session working directory');
|
|
83
|
+
}
|
|
84
|
+
return candidate;
|
|
85
|
+
}
|
|
86
|
+
async function run(t, argv) {
|
|
87
|
+
const { file, args } = commandArgv(t, argv);
|
|
88
|
+
return runCommand(file, args, { timeoutMs: timeout(t) });
|
|
89
|
+
}
|
|
90
|
+
/** The working directory of a session's active pane (tmux #{pane_current_path}). */
|
|
91
|
+
export async function getSessionCwd(t, session) {
|
|
92
|
+
const { file, args } = commandArgv(t, [
|
|
93
|
+
'tmux',
|
|
94
|
+
'display-message',
|
|
95
|
+
'-p',
|
|
96
|
+
'-t',
|
|
97
|
+
session,
|
|
98
|
+
'#{pane_current_path}',
|
|
99
|
+
]);
|
|
100
|
+
const r = await runCommand(file, args, { timeoutMs: timeout(t) });
|
|
101
|
+
if (r.code !== 0) {
|
|
102
|
+
if (/can't find|no server running|session not found/i.test(r.stderr)) {
|
|
103
|
+
throw new TmuxError(404, `session "${session}" not found`);
|
|
104
|
+
}
|
|
105
|
+
throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot read session cwd');
|
|
106
|
+
}
|
|
107
|
+
const cwd = r.stdout.trim();
|
|
108
|
+
if (!cwd)
|
|
109
|
+
throw new TmuxError(502, 'empty session cwd');
|
|
110
|
+
return cwd;
|
|
111
|
+
}
|
|
112
|
+
function sortEntries(entries) {
|
|
113
|
+
return entries.sort((a, b) => {
|
|
114
|
+
if (a.type !== b.type)
|
|
115
|
+
return a.type === 'dir' ? -1 : 1;
|
|
116
|
+
return a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' });
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
/** List a directory ON THE TARGET. Local uses fs; wsl/ssh shell out to `ls`. */
|
|
120
|
+
export async function listDirectory(t, path) {
|
|
121
|
+
if (t.kind === 'local') {
|
|
122
|
+
let dirents;
|
|
123
|
+
try {
|
|
124
|
+
dirents = await fsp.readdir(path, { withFileTypes: true });
|
|
125
|
+
}
|
|
126
|
+
catch (e) {
|
|
127
|
+
throw fsError(e, path);
|
|
128
|
+
}
|
|
129
|
+
const entries = [];
|
|
130
|
+
for (const d of dirents) {
|
|
131
|
+
if (d.name.startsWith('.') && (d.name === '.' || d.name === '..'))
|
|
132
|
+
continue;
|
|
133
|
+
let isDir = d.isDirectory();
|
|
134
|
+
if (d.isSymbolicLink()) {
|
|
135
|
+
// Resolve symlinks so linked dirs are navigable.
|
|
136
|
+
try {
|
|
137
|
+
const st = await fsp.stat(path.replace(/\/$/, '') + '/' + d.name);
|
|
138
|
+
isDir = st.isDirectory();
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
isDir = false;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
entries.push({ name: d.name, type: isDir ? 'dir' : 'file' });
|
|
145
|
+
}
|
|
146
|
+
return sortEntries(entries);
|
|
147
|
+
}
|
|
148
|
+
// wsl/ssh: `ls -Ap1 -- <path>` → one per line, dirs end with "/".
|
|
149
|
+
const r = await run(t, ['ls', '-Ap1', '--', path]);
|
|
150
|
+
if (r.code !== 0) {
|
|
151
|
+
if (/No such file|not found/i.test(r.stderr))
|
|
152
|
+
throw new TmuxError(404, 'directory not found');
|
|
153
|
+
if (/Permission denied/i.test(r.stderr))
|
|
154
|
+
throw new TmuxError(403, 'permission denied');
|
|
155
|
+
throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot list directory');
|
|
156
|
+
}
|
|
157
|
+
const entries = r.stdout
|
|
158
|
+
.split('\n')
|
|
159
|
+
.filter((l) => l.length > 0)
|
|
160
|
+
.map((line) => {
|
|
161
|
+
const isDir = line.endsWith('/');
|
|
162
|
+
const name = isDir ? line.slice(0, -1) : line;
|
|
163
|
+
return { name, type: isDir ? 'dir' : 'file' };
|
|
164
|
+
})
|
|
165
|
+
.filter((e) => e.name && e.name !== '.' && e.name !== '..');
|
|
166
|
+
return sortEntries(entries);
|
|
167
|
+
}
|
|
168
|
+
/** Read a (capped) file preview ON THE TARGET. */
|
|
169
|
+
export async function readFilePreview(t, path) {
|
|
170
|
+
if (t.kind === 'local') {
|
|
171
|
+
let fh;
|
|
172
|
+
try {
|
|
173
|
+
fh = await fsp.open(path, 'r');
|
|
174
|
+
}
|
|
175
|
+
catch (e) {
|
|
176
|
+
throw fsError(e, path);
|
|
177
|
+
}
|
|
178
|
+
try {
|
|
179
|
+
const st = await fh.stat();
|
|
180
|
+
if (st.isDirectory())
|
|
181
|
+
throw new TmuxError(400, 'path is a directory');
|
|
182
|
+
const cap = FILE_PREVIEW_CAP;
|
|
183
|
+
const buf = Buffer.alloc(Math.min(cap, Number(st.size)));
|
|
184
|
+
const { bytesRead } = await fh.read(buf, 0, buf.length, 0);
|
|
185
|
+
const slice = buf.subarray(0, bytesRead);
|
|
186
|
+
return {
|
|
187
|
+
path,
|
|
188
|
+
content: slice.toString('utf8'),
|
|
189
|
+
truncated: st.size > cap,
|
|
190
|
+
binary: slice.includes(0),
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
finally {
|
|
194
|
+
await fh.close();
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
// wsl/ssh: read up to cap+1 bytes; if we got cap+1 the file is larger.
|
|
198
|
+
const r = await run(t, ['head', '-c', String(FILE_PREVIEW_CAP + 1), '--', path]);
|
|
199
|
+
if (r.code !== 0) {
|
|
200
|
+
if (/No such file|not found/i.test(r.stderr))
|
|
201
|
+
throw new TmuxError(404, 'file not found');
|
|
202
|
+
if (/Is a directory/i.test(r.stderr))
|
|
203
|
+
throw new TmuxError(400, 'path is a directory');
|
|
204
|
+
if (/Permission denied/i.test(r.stderr))
|
|
205
|
+
throw new TmuxError(403, 'permission denied');
|
|
206
|
+
throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot read file');
|
|
207
|
+
}
|
|
208
|
+
const truncated = r.stdout.length > FILE_PREVIEW_CAP;
|
|
209
|
+
const content = truncated ? r.stdout.slice(0, FILE_PREVIEW_CAP) : r.stdout;
|
|
210
|
+
return { path, content, truncated, binary: content.includes(NUL) };
|
|
211
|
+
}
|
|
212
|
+
/** Overwrite a file ON THE TARGET with `content` (UTF-8). Local uses fs;
|
|
213
|
+
* wsl/ssh pipe the bytes into `tee -- <path>` over stdin (tee truncates). */
|
|
214
|
+
export async function writeFile(t, path, content) {
|
|
215
|
+
if (t.kind === 'local') {
|
|
216
|
+
try {
|
|
217
|
+
await fsp.writeFile(path, content, 'utf8');
|
|
218
|
+
}
|
|
219
|
+
catch (e) {
|
|
220
|
+
throw fsError(e, path);
|
|
221
|
+
}
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
// `tee` opens the file with O_TRUNC|O_CREAT, so shorter content can't leave a
|
|
225
|
+
// stale tail. Its stdout echo of the content is ignored.
|
|
226
|
+
const { file, args } = commandArgv(t, ['tee', '--', path]);
|
|
227
|
+
const r = await runCommand(file, args, { timeoutMs: REMOTE_TIMEOUT_MS, input: content });
|
|
228
|
+
if (r.code !== 0) {
|
|
229
|
+
if (/No such file|not found/i.test(r.stderr))
|
|
230
|
+
throw new TmuxError(404, 'directory not found');
|
|
231
|
+
if (/Is a directory/i.test(r.stderr))
|
|
232
|
+
throw new TmuxError(400, 'path is a directory');
|
|
233
|
+
if (/Permission denied/i.test(r.stderr))
|
|
234
|
+
throw new TmuxError(403, 'permission denied');
|
|
235
|
+
throw new TmuxError(502, r.stderr.trim().split('\n')[0] || 'cannot write file');
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
function fsError(e, path) {
|
|
239
|
+
const code = e?.code;
|
|
240
|
+
if (code === 'ENOENT')
|
|
241
|
+
return new TmuxError(404, `not found: ${path}`);
|
|
242
|
+
if (code === 'EACCES')
|
|
243
|
+
return new TmuxError(403, `permission denied: ${path}`);
|
|
244
|
+
if (code === 'ENOTDIR')
|
|
245
|
+
return new TmuxError(400, `not a directory: ${path}`);
|
|
246
|
+
if (code === 'EISDIR')
|
|
247
|
+
return new TmuxError(400, `path is a directory: ${path}`);
|
|
248
|
+
return new TmuxError(502, e instanceof Error ? e.message : 'filesystem error');
|
|
249
|
+
}
|
|
250
|
+
//# sourceMappingURL=files.js.map
|