@dadigua/web-terminal 0.1.3 → 0.1.4

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 CHANGED
@@ -48,11 +48,15 @@ CLI 默认使用 `~/.web-terminal/config.yaml`,相对路径以配置文件所
48
48
 
49
49
  CLI 只停止自己登记且启动标识匹配的进程,不接管 `pnpm start` 或 systemd 启动的外部服务。外部服务可以连接,重启时会提示使用原管理器。
50
50
 
51
+ ## 发布
52
+
53
+ 推送与 `package.json` 版本一致的 `v*` 标签后,`.github/workflows/publish.yaml` 自动检查、构建、打包,并通过 npm Trusted Publishing 发布,再创建 GitHub Release 和上传附件。npm 仅信任 `laopo001/web-terminal` 仓库中的此工作流,不需要发布 token。手动运行工作流只验证和生成产物。
54
+
51
55
  ## 使用
52
56
 
53
57
  1. 首次打开时选择已有会话,或点击“新建 Shell”进入普通命令行;不会自动接入第一条历史会话。通过“+”选择工作目录;VS Code 中可快捷选择工作区目录。
54
58
  2. 在终端里运行 `codex`,照常操作 CLI。 底部输入框点击“发送”或按 Enter 会写入文字并提交一次;中文输入法选词时不会提交。
55
- 3. 粘贴截图、拖入图片,或点击工具栏的附件按钮。图片保存到服务器的 `.data/uploads/<会话ID>/`,附件区显示预览、上传状态与移除按钮;点击“发送”时,将文字与附件路径一起送入当前终端。支持多图和全部清除。
59
+ 3. 粘贴截图、拖入图片,或点击工具栏的附件按钮。图片默认保存到服务器的 `~/.web-terminal/uploads/<12位短文件名>.png`(扩展名随图片格式变化,可通过 `uploadDir` 配置目录),附件区显示预览、上传状态与移除按钮;点击“发送”时,将文字与附件路径一起送入当前终端。支持多图和全部清除。
56
60
  4. 悬停图片路径查看缩略图;在浏览器中点击文件路径,弹出图片或文本预览对话框,可复制路径、下载文件,按 Esc 或点击遮罩关闭。Markdown、代码和 YAML 等文本按原文显示,最多 1 MiB。VS Code 内点击路径会交给编辑器打开文件。
57
61
  5. VS Code、Electron 和浏览器连接同一服务器并选择同一会话,就会共用一个终端;两端输入会进入同一个 Shell,当前操作的窗口控制尺寸,其他窗口按相同行列数显示。关闭浏览器不会结束会话。默认直接运行 Shell,重启 Web 服务会结束普通 Shell;点击会话的结束按钮才会终止 Shell 并删除该会话上传的图片。
58
62
 
@@ -90,11 +94,21 @@ ssh -N -L 3840:127.0.0.1:3840 user@server
90
94
 
91
95
  ### VS Code
92
96
 
97
+ 安装全局 CLI 后,用一条命令安装 npm 包自带的插件:
98
+
99
+ ```bash
100
+ web-terminal install-vscode
101
+ ```
102
+
103
+ 自动安装到检测到的 VS Code Stable 和 Insiders;WSL 下同时安装 WSL 扩展宿主和 Windows 宿主。已打开的窗口需运行 `Developer: Reload Window` 加载插件。可用 `web-terminal install-vscode --vsix /path/web-terminal.vsix` 安装指定文件。
104
+
105
+ 本地开发打包并安装:
106
+
93
107
  ```bash
94
- pnpm package:vscode
108
+ pnpm install:vscode
95
109
  ```
96
110
 
97
- 在 VS Code 的扩展菜单选择“从 VSIX 安装”,打开 `release/web-terminal.vsix`。点击活动栏终端图标或运行 `Web Terminal: Show Sidebar` 打开侧边窗口;`Web Terminal: Open` 在编辑器标签页打开。两处均加载现有 Web 页面。默认地址为 `http://localhost:3840`;通过 `Web Terminal: Set Server URL` 或设置 `webTerminal.serverUrl` 修改,`Web Terminal: Reload` 重载;侧栏及编辑器标题栏的重启按钮重启后台并等待就绪。Remote WSL/SSH 在扩展宿主所在机器检测和启动,再使用 VS Code 的端口转发能力。Windows 本机默认先查本机全局 CLI,再查默认 WSL;可通过 `webTerminal.runtime` 和 `webTerminal.cliPath` 调整。
111
+ 也可运行 `pnpm package:vscode`,在 VS Code 的扩展菜单选择“从 VSIX 安装”,打开 `release/web-terminal.vsix`。点击活动栏终端图标或运行 `Web Terminal: Show Sidebar` 打开侧边窗口;`Web Terminal: Open` 在编辑器标签页打开。两处均加载现有 Web 页面。默认地址为 `http://localhost:3840`;通过 `Web Terminal: Set Server URL` 或设置 `webTerminal.serverUrl` 修改,`Web Terminal: Reload` 重载;侧栏及编辑器标题栏的重启按钮重启后台并等待就绪。Remote WSL/SSH 在扩展宿主所在机器检测和启动,再使用 VS Code 的端口转发能力。Windows 本机默认先查本机全局 CLI,再查默认 WSL;可通过 `webTerminal.runtime` 和 `webTerminal.cliPath` 调整。
98
112
 
99
113
  选中编辑器中的文本后,右键选择“发送选中文本到 Web Terminal”,以附件形式加入当前会话,带有文件路径、语言与行号。点击发送后送入终端。
100
114
 
@@ -105,7 +119,7 @@ pnpm electron
105
119
  pnpm package:electron:win
106
120
  ```
107
121
 
108
- 开发命令打开桌面客户端;Windows 命令生成 `release/Web-Terminal-0.1.3-win-x64.zip`,解压后运行 `Web Terminal.exe`。菜单“连接 → 打开连接配置”打开 `client.yaml`,只需修改 `serverUrl`,再选择“重新加载服务器”;“重启后台服务”通过全局 CLI 重启。`client.yaml` 的 `runtime` 可选 auto、native、wsl,`cliPath` 可指定本机 CLI 路径。也可用 `WEB_TERMINAL_URL` 临时覆盖地址。Linux 目录包通过 `pnpm package:electron:linux` 生成。
122
+ 开发命令打开桌面客户端;Windows 命令生成 `release/Web-Terminal-0.1.4-win-x64.zip`,解压后运行 `Web Terminal.exe`。菜单“连接 → 打开连接配置”打开 `client.yaml`,只需修改 `serverUrl`,再选择“重新加载服务器”;“重启后台服务”通过全局 CLI 重启。`client.yaml` 的 `runtime` 可选 auto、native、wsl,`cliPath` 可指定本机 CLI 路径。也可用 `WEB_TERMINAL_URL` 临时覆盖地址。Linux 目录包通过 `pnpm package:electron:linux` 生成。
109
123
 
110
124
  浏览器、VS Code、Electron 各自首次在同一 Web 登录页输入 token,验证后保存在各自 Webview 的持久 localStorage 中。客户端配置不保存 token。Electron 远程页面开启 sandbox/contextIsolation、关闭 Node 集成;VS Code 通过专用 `/?embed=vscode` 入口加载,普通入口仍禁止 iframe 嵌入。
111
125
 
@@ -5,3 +5,5 @@ port: 3840
5
5
  # defaultCwd: /home/yourname/projects
6
6
  # shell: /bin/zsh
7
7
  dataDir: .data
8
+ # 图片默认保存在 ~/.web-terminal/uploads,可指定其他目录。
9
+ # uploadDir: /home/yourname/.web-terminal/uploads
@@ -2,7 +2,7 @@ import {
2
2
  serviceName,
3
3
  serviceProtocol,
4
4
  serviceVersion
5
- } from "./chunk-R33XRLBM.js";
5
+ } from "./chunk-TD3NJESY.js";
6
6
 
7
7
  // src/server/app.ts
8
8
  import express from "express";
@@ -44,9 +44,10 @@ function authenticate(token) {
44
44
 
45
45
  // src/server/files.ts
46
46
  import { constants } from "node:fs";
47
- import { mkdir, readdir, open, realpath, rename, unlink } from "node:fs/promises";
47
+ import { mkdir, readdir, open, realpath, unlink, writeFile, appendFile, readFile, rm } from "node:fs/promises";
48
48
  import { resolve, basename, dirname, join } from "node:path";
49
- import { randomUUID } from "node:crypto";
49
+ import { randomBytes } from "node:crypto";
50
+ import { parse, stringify } from "yaml";
50
51
  import sharp from "sharp";
51
52
  var HttpError = class extends Error {
52
53
  constructor(status, message) {
@@ -129,15 +130,43 @@ var Files = class {
129
130
  if (!meta.width || !meta.height) throw new HttpError(415, "\u56FE\u7247\u65E0\u6CD5\u89E3\u7801");
130
131
  const folder = join(this.config.dataDir, "uploads", sessionId);
131
132
  await mkdir(folder, { recursive: true, mode: 448 });
133
+ await mkdir(this.config.uploadDir, { recursive: true, mode: 448 });
132
134
  const ext = { "image/png": ".png", "image/jpeg": ".jpg", "image/webp": ".webp", "image/gif": ".gif" }[mime];
133
- const path = join(folder, `${randomUUID()}${ext}`);
134
- await rename(temporary, path);
135
+ let path;
136
+ for (; ; ) {
137
+ path = join(this.config.uploadDir, `${randomBytes(6).toString("hex")}${ext}`);
138
+ try {
139
+ await writeFile(path, bytes, { flag: "wx", mode: 384 });
140
+ break;
141
+ } catch (error) {
142
+ if (error.code !== "EEXIST") throw error;
143
+ }
144
+ }
145
+ try {
146
+ await appendFile(join(folder, "files.yaml"), stringify([path]), { mode: 384 });
147
+ } catch (error) {
148
+ await unlink(path);
149
+ throw error;
150
+ }
135
151
  return { path, name: basename(originalName).replace(/[\x00-\x1f\x7f]/g, "").slice(0, 160) || basename(path), size: bytes.length, mime, isImage: true, width: meta.width, height: meta.height };
136
152
  } finally {
137
153
  await unlink(temporary).catch(() => {
138
154
  });
139
155
  }
140
156
  }
157
+ async removeUploads(sessionId) {
158
+ const folder = join(this.config.dataDir, "uploads", sessionId);
159
+ let paths;
160
+ try {
161
+ paths = parse(await readFile(join(folder, "files.yaml"), "utf8"));
162
+ } catch (error) {
163
+ if (error.code !== "ENOENT") throw error;
164
+ }
165
+ if (Array.isArray(paths)) {
166
+ await Promise.all(paths.filter((path) => typeof path === "string" && /^[a-f0-9]{12}\.(?:png|jpg|webp|gif)$/.test(basename(path))).map((path) => rm(path, { force: true })));
167
+ }
168
+ await rm(folder, { recursive: true, force: true });
169
+ }
141
170
  };
142
171
  function imageMime(bytes) {
143
172
  if (bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]))) return "image/png";
@@ -148,10 +177,9 @@ function imageMime(bytes) {
148
177
 
149
178
  // src/server/sessions.ts
150
179
  import { readFileSync, writeFileSync, renameSync, existsSync } from "node:fs";
151
- import { rm } from "node:fs/promises";
152
180
  import { join as join2 } from "node:path";
153
- import { randomUUID as randomUUID2 } from "node:crypto";
154
- import { parse, stringify } from "yaml";
181
+ import { randomUUID } from "node:crypto";
182
+ import { parse as parse2, stringify as stringify2 } from "yaml";
155
183
  import * as pty from "node-pty";
156
184
 
157
185
  // src/shared/terminalInput.ts
@@ -161,12 +189,12 @@ function pasteText(text, bracketedPaste) {
161
189
  }
162
190
 
163
191
  // src/server/processInfo.ts
164
- import { readFile, readlink } from "node:fs/promises";
192
+ import { readFile as readFile2, readlink } from "node:fs/promises";
165
193
  import { basename as basename2 } from "node:path";
166
194
  async function foregroundInfo(pid, fallback) {
167
195
  let foreground = pid;
168
196
  try {
169
- const stat = await readFile(`/proc/${pid}/stat`, "utf8");
197
+ const stat = await readFile2(`/proc/${pid}/stat`, "utf8");
170
198
  const group = Number(stat.slice(stat.lastIndexOf(")") + 2).split(" ")[5]);
171
199
  if (Number.isInteger(group) && group > 0) foreground = group;
172
200
  } catch {
@@ -182,7 +210,7 @@ async function foregroundInfo(pid, fallback) {
182
210
  if (/\/claude\/versions\//.test(executable)) processName = "claude";
183
211
  else if (basename2(executable) === "codex") processName = "codex";
184
212
  else if (/^(node|nodejs|bun)$/.test(basename2(executable))) {
185
- const argv = (await readFile(`/proc/${foreground}/cmdline`, "utf8")).split("\0", 2);
213
+ const argv = (await readFile2(`/proc/${foreground}/cmdline`, "utf8")).split("\0", 2);
186
214
  if (/[/\\](?:@anthropic-ai[/\\]claude-code|claude-code)[/\\]/.test(argv[1] || "")) processName = "claude";
187
215
  else if (/[/\\]@openai[/\\]codex[/\\]/.test(argv[1] || "")) processName = "codex";
188
216
  }
@@ -344,7 +372,7 @@ var Sessions = class {
344
372
  this.files = files;
345
373
  const saved = join2(config.dataDir, "sessions.yaml");
346
374
  if (existsSync(saved)) {
347
- const records = parse(readFileSync(saved, "utf8"));
375
+ const records = parse2(readFileSync(saved, "utf8"));
348
376
  if (!Array.isArray(records)) throw new Error("sessions.yaml \u683C\u5F0F\u65E0\u6548");
349
377
  for (const record of records) {
350
378
  if (!/^[a-f0-9-]{36}$/.test(record.id)) continue;
@@ -369,7 +397,7 @@ var Sessions = class {
369
397
  }
370
398
  save() {
371
399
  const file = join2(this.config.dataDir, "sessions.yaml");
372
- writeFileSync(`${file}.tmp`, stringify([...this.entries.values()].map((s) => s.info)), { mode: 384 });
400
+ writeFileSync(`${file}.tmp`, stringify2([...this.entries.values()].map((s) => s.info)), { mode: 384 });
373
401
  renameSync(`${file}.tmp`, file);
374
402
  }
375
403
  get(id) {
@@ -397,7 +425,7 @@ var Sessions = class {
397
425
  if (name !== void 0 && (typeof name !== "string" || name.length > 80)) throw new HttpError(400, "\u4F1A\u8BDD\u540D\u79F0\u6700\u591A 80 \u5B57\u7B26");
398
426
  if (cwd !== void 0 && typeof cwd !== "string") throw new HttpError(400, "\u5DE5\u4F5C\u76EE\u5F55\u65E0\u6548");
399
427
  const dir = await this.files.directory(cwd || this.config.defaultCwd);
400
- const id = randomUUID2();
428
+ const id = randomUUID();
401
429
  const info = { id, name: name?.trim() || `\u7EC8\u7AEF ${this.entries.size + 1}`, cwd: dir, createdAt: (/* @__PURE__ */ new Date()).toISOString(), running: true };
402
430
  const session = this.makeSession(info);
403
431
  try {
@@ -526,7 +554,7 @@ var Sessions = class {
526
554
  this.entries.delete(id);
527
555
  this.save();
528
556
  await session.screen.dispose();
529
- await rm(join2(this.config.dataDir, "uploads", id), { recursive: true, force: true });
557
+ await this.files.removeUploads(id);
530
558
  }
531
559
  async shutdown() {
532
560
  this.shuttingDown = true;
@@ -570,12 +598,12 @@ function embeddingHeaders(path, embed, dev) {
570
598
  }
571
599
 
572
600
  // src/server/app.ts
573
- import { randomUUID as randomUUID3 } from "node:crypto";
601
+ import { randomUUID as randomUUID2 } from "node:crypto";
574
602
  import { fileURLToPath } from "node:url";
575
603
  async function createApp(config, options = {}) {
576
604
  mkdirSync(config.dataDir, { recursive: true, mode: 448 });
577
605
  const app = express();
578
- const instanceId = randomUUID3();
606
+ const instanceId = randomUUID2();
579
607
  app.disable("x-powered-by");
580
608
  const server = createServer(app);
581
609
  const files = new Files(config);
@@ -1,7 +1,7 @@
1
1
  // package.json
2
2
  var package_default = {
3
3
  name: "@dadigua/web-terminal",
4
- version: "0.1.3",
4
+ version: "0.1.4",
5
5
  type: "module",
6
6
  scripts: {
7
7
  dev: "tsx src/server/index.ts --dev",
@@ -11,10 +11,11 @@ var package_default = {
11
11
  check: "tsc --noEmit",
12
12
  "build:clients": "tsx scripts/build-clients.ts",
13
13
  "package:vscode": "pnpm build:clients && tsx scripts/package-vscode.ts",
14
+ "install:vscode": "pnpm package:vscode && tsx src/cli.ts install-vscode",
14
15
  electron: "pnpm build:clients && electron dist-clients/electron",
15
16
  "package:electron:win": "pnpm build:clients && tsx scripts/package-electron.ts --win",
16
17
  "package:electron:linux": "pnpm build:clients && tsx scripts/package-electron.ts --linux",
17
- prepack: "pnpm build"
18
+ prepack: "pnpm build && pnpm package:vscode"
18
19
  },
19
20
  dependencies: {
20
21
  "@xterm/addon-fit": "^0.11.0",
@@ -50,7 +51,7 @@ var package_default = {
50
51
  engines: {
51
52
  node: ">=22"
52
53
  },
53
- packageManager: "pnpm@11.12.0",
54
+ packageManager: "pnpm@11.28.2",
54
55
  bin: {
55
56
  "web-terminal": "bin/web-terminal.mjs"
56
57
  },
@@ -58,6 +59,7 @@ var package_default = {
58
59
  "bin/",
59
60
  "dist-server/",
60
61
  "dist/",
62
+ "release/web-terminal.vsix",
61
63
  "config.example.yaml",
62
64
  "README.md",
63
65
  "API.md"
@@ -2,7 +2,7 @@ import {
2
2
  serviceName,
3
3
  serviceProtocol,
4
4
  serviceVersion
5
- } from "./chunk-R33XRLBM.js";
5
+ } from "./chunk-TD3NJESY.js";
6
6
 
7
7
  // src/cli.ts
8
8
  import { fileURLToPath } from "node:url";
@@ -34,11 +34,13 @@ function loadConfig(defaults = {}) {
34
34
  if (!token || token.length > 1024) throw new Error("token \u4E0D\u80FD\u4E3A\u7A7A\uFF0C\u4E14\u6700\u591A 1024 \u4E2A\u5B57\u7B26");
35
35
  const port = Number(process.env.PORT || input.port || 3840);
36
36
  if (!Number.isInteger(port) || port < 1 || port > 65535) throw new Error("port \u65E0\u6548");
37
+ const uploadHome = resolve(process.env.WEB_TERMINAL_HOME || resolve(homedir(), ".web-terminal"));
37
38
  return {
38
39
  host: String(process.env.HOST || input.host || "127.0.0.1"),
39
40
  port,
40
41
  dataDir,
41
42
  token,
43
+ uploadDir: resolve(input.uploadDir || defaults.uploadDir || resolve(uploadHome, "uploads")),
42
44
  defaultCwd: realpathSync(resolve(input.defaultCwd || homedir())),
43
45
  shell: String(input.shell || process.env.SHELL || (process.platform === "win32" ? "powershell.exe" : "/bin/bash")),
44
46
  machineId: `${hostname()}-${createHash("sha256").update(dataDir).digest("hex").slice(0, 8)}`,
@@ -292,7 +294,8 @@ async function main() {
292
294
  home: { type: "string" },
293
295
  config: { type: "string" },
294
296
  version: { type: "boolean" },
295
- help: { type: "boolean" }
297
+ help: { type: "boolean" },
298
+ vsix: { type: "string" }
296
299
  } });
297
300
  if (values.version) {
298
301
  console.log(JSON.stringify({ service: serviceName, version: serviceVersion }));
@@ -300,9 +303,16 @@ async function main() {
300
303
  }
301
304
  const action = positionals[0] || "start";
302
305
  if (values.help) {
303
- console.log("web-terminal [start|restart|stop|status] [--url http://localhost:3840] [--config /path/config.yaml] [--home /path/state]\n\u914D\u7F6E\u3001token\u3001\u8FDB\u7A0B\u72B6\u6001\u548C\u65E5\u5FD7\u9ED8\u8BA4\u4F4D\u4E8E ~/.web-terminal/\u3002\u91CD\u542F\u4F1A\u7ED3\u675F\u666E\u901A Shell \u4F1A\u8BDD\u3002");
306
+ console.log("web-terminal [start|restart|stop|status] [--url http://localhost:3840] [--config /path/config.yaml] [--home /path/state]\nweb-terminal install-vscode [--vsix /path/web-terminal.vsix]\n\u5B89\u88C5\u81EA\u5E26\u63D2\u4EF6\u5230\u5DF2\u68C0\u6D4B\u5230\u7684 VS Code Stable/Insiders\uFF1BWSL \u4E0B\u540C\u65F6\u5B89\u88C5 Windows \u5BBF\u4E3B\u3002\n\u914D\u7F6E\u3001token\u3001\u8FDB\u7A0B\u72B6\u6001\u548C\u65E5\u5FD7\u9ED8\u8BA4\u4F4D\u4E8E ~/.web-terminal/\u3002\u91CD\u542F\u4F1A\u7ED3\u675F\u666E\u901A Shell \u4F1A\u8BDD\u3002");
304
307
  return;
305
308
  }
309
+ if (action === "install-vscode") {
310
+ const { installVSCodeExtension } = await import("./vscodeInstaller-WIM5VHDI.js");
311
+ await installVSCodeExtension({ vsixPath: values.vsix, onInstalled: (result) => console.log(`${result.target} \u5DF2\u5B89\u88C5 ${result.extension}`) });
312
+ console.log("\u8BF7\u5728\u5DF2\u6253\u5F00\u7684 VS Code \u7A97\u53E3\u8FD0\u884C Developer: Reload Window \u4EE5\u52A0\u8F7D\u63D2\u4EF6\u3002");
313
+ return;
314
+ }
315
+ if (values.vsix) throw new Error("--vsix \u4EC5\u7528\u4E8E install-vscode");
306
316
  const url = normalizeServerUrl(values.url);
307
317
  if (action === "status") {
308
318
  console.log(JSON.stringify(await probeService(url)));
@@ -319,11 +329,11 @@ async function main() {
319
329
  const configFile = resolve3(values.config || process.env.WEB_TERMINAL_CONFIG || paths.config);
320
330
  process.env.WEB_TERMINAL_CONFIG = configFile;
321
331
  process.chdir(dirname(configFile));
322
- const config = loadConfig({ dataDir: join2(paths.directory, "data") });
332
+ const config = loadConfig({ dataDir: join2(paths.directory, "data"), uploadDir: join2(paths.base, "uploads") });
323
333
  config.host = new URL(url).hostname === "localhost" ? "127.0.0.1" : new URL(url).hostname.replace(/^\[|\]$/g, "");
324
334
  config.port = Number(new URL(url).port || 80);
325
335
  const instanceId = randomUUID2();
326
- const { createApp } = await import("./app-TCUYGJ4K.js");
336
+ const { createApp } = await import("./app-3OE4ER7S.js");
327
337
  const runtime = await createApp(config, { managed: true, instanceId, staticDir: fileURLToPath(new URL("../dist/", import.meta.url)) });
328
338
  const stamp = await processStamp(process.pid);
329
339
  if (!stamp) throw new Error("\u65E0\u6CD5\u53D6\u5F97\u8FDB\u7A0B\u542F\u52A8\u6807\u8BC6\uFF0C\u62D2\u7EDD\u542F\u52A8\u4E0D\u53EF\u5B89\u5168\u7BA1\u7406\u7684\u540E\u53F0");
@@ -0,0 +1,210 @@
1
+ import {
2
+ serviceVersion
3
+ } from "./chunk-TD3NJESY.js";
4
+
5
+ // src/vscodeInstaller.ts
6
+ import { execFile } from "node:child_process";
7
+ import { access, readFile, readdir, stat } from "node:fs/promises";
8
+ import { constants } from "node:fs";
9
+ import { homedir } from "node:os";
10
+ import { delimiter, join, resolve } from "node:path";
11
+ import { fileURLToPath } from "node:url";
12
+ import { promisify } from "node:util";
13
+ var execute = promisify(execFile);
14
+ var extensionId = "dadigua.web-terminal";
15
+ var psQuote = (value) => `'${value.replaceAll("'", "''")}'`;
16
+ async function exists(path) {
17
+ try {
18
+ await access(path);
19
+ return true;
20
+ } catch {
21
+ return false;
22
+ }
23
+ }
24
+ async function findServerLauncher(root) {
25
+ const cliRoot = join(root, "cli/servers");
26
+ let recent = [];
27
+ try {
28
+ const value = JSON.parse(await readFile(join(cliRoot, "lru.json"), "utf8"));
29
+ if (Array.isArray(value)) recent = value.filter((item) => typeof item === "string" && !item.includes("/") && !item.includes("\\"));
30
+ } catch {
31
+ }
32
+ const candidates = [
33
+ ...(await readdir(cliRoot).catch(() => [])).map((name) => ({ name, path: join(cliRoot, name, "server") })),
34
+ ...(await readdir(join(root, "bin")).catch(() => [])).map((name) => ({ name, path: join(root, "bin", name) }))
35
+ ];
36
+ const available = [];
37
+ for (const candidate of candidates) {
38
+ const entry = join(candidate.path, "out/server-main.js");
39
+ if (!await exists(entry)) continue;
40
+ available.push({ ...candidate, entry, mtime: (await stat(candidate.path)).mtimeMs });
41
+ }
42
+ available.sort((a, b) => {
43
+ const rank = (name) => recent.includes(name) ? recent.indexOf(name) : Infinity;
44
+ return rank(a.name) - rank(b.name) || b.mtime - a.mtime;
45
+ });
46
+ const server = available[0];
47
+ if (!server) return void 0;
48
+ const node = join(server.path, "node");
49
+ return { file: await exists(node) ? node : process.execPath, args: [server.entry, "--extensions-dir", join(root, "extensions")] };
50
+ }
51
+ async function findCommand(command) {
52
+ for (const directory of (process.env.PATH || "").split(delimiter)) {
53
+ const candidate = resolve(directory, command);
54
+ try {
55
+ await access(candidate, constants.X_OK);
56
+ return candidate;
57
+ } catch {
58
+ }
59
+ }
60
+ return void 0;
61
+ }
62
+ async function isWsl() {
63
+ if (process.platform !== "linux") return false;
64
+ if (process.env.WSL_DISTRO_NAME) return true;
65
+ return (await readFile("/proc/version", "utf8").catch(() => "")).toLowerCase().includes("microsoft");
66
+ }
67
+ async function localLaunchers(options, wsl) {
68
+ const launchers = [];
69
+ for (const [channel, command, override, folder] of [
70
+ ["Stable", "code", options.codeCommand, ".vscode-server"],
71
+ ["Insiders", "code-insiders", options.codeInsidersCommand, ".vscode-server-insiders"]
72
+ ]) {
73
+ if (override) {
74
+ launchers.push({ label: `\u672C\u673A ${channel}`, file: override, args: [] });
75
+ continue;
76
+ }
77
+ if (process.platform === "linux") {
78
+ const defaultRoot = join(homedir(), folder);
79
+ const agentRoot = process.env.VSCODE_AGENT_FOLDER;
80
+ const root = agentRoot && agentRoot.includes("insiders") === (channel === "Insiders") ? agentRoot : defaultRoot;
81
+ const server = await findServerLauncher(root);
82
+ if (server) {
83
+ launchers.push({ label: `${wsl ? "WSL" : "Remote"} ${channel}`, ...server });
84
+ continue;
85
+ }
86
+ }
87
+ const file = await findCommand(command);
88
+ if (file && !(wsl && (file.startsWith("/mnt/") || file.includes("/remote-cli/")))) {
89
+ launchers.push({ label: `\u672C\u673A ${channel}`, file, args: [] });
90
+ }
91
+ }
92
+ return launchers;
93
+ }
94
+ async function invoke(launcher, args) {
95
+ const { stdout } = await execute(launcher.file, [...launcher.args, ...args], {
96
+ timeout: 6e4,
97
+ windowsHide: true,
98
+ maxBuffer: 4 * 1024 * 1024
99
+ });
100
+ if (stdout.includes("Command is only available in WSL or inside a Visual Studio Code terminal")) {
101
+ throw new Error(stdout.trim());
102
+ }
103
+ return stdout;
104
+ }
105
+ function verifyExtension(output, version) {
106
+ const extension = output.split(/\r?\n/).map((line) => line.trim()).find((line) => line.toLowerCase().startsWith(`${extensionId}@`));
107
+ if (!extension || version && extension !== `${extensionId}@${version}`) {
108
+ throw new Error(`\u5B89\u88C5\u540E\u672A\u627E\u5230 ${extensionId}${version ? `@${version}` : ""}\uFF0C\u6269\u5C55\u5217\u8868\uFF1A
109
+ ${output.trim()}`);
110
+ }
111
+ return extension;
112
+ }
113
+ async function installWindows(vsix, wsl, version) {
114
+ const source = wsl ? (await execute("wslpath", ["-w", vsix])).stdout.trim() : vsix;
115
+ const script = `
116
+ $ErrorActionPreference = 'Stop'
117
+ $temporary = $null
118
+ try {
119
+ Set-Location $env:SystemDrive\\
120
+ $vsix = ${psQuote(source)}
121
+ if (${wsl ? "$true" : "$false"}) {
122
+ $temporary = Join-Path $env:TEMP ('web-terminal-' + [guid]::NewGuid() + '.vsix')
123
+ Copy-Item -LiteralPath $vsix -Destination $temporary
124
+ $vsix = $temporary
125
+ }
126
+ $results = @()
127
+ $failures = @()
128
+ foreach ($channel in @(
129
+ @{ Name = 'Stable'; Command = 'code.cmd'; Folder = 'Microsoft VS Code' },
130
+ @{ Name = 'Insiders'; Command = 'code-insiders.cmd'; Folder = 'Microsoft VS Code Insiders' }
131
+ )) {
132
+ $command = (Get-Command $channel.Command -ErrorAction SilentlyContinue | Select-Object -First 1).Source
133
+ if (-not $command) {
134
+ foreach ($base in @((Join-Path $env:LOCALAPPDATA 'Programs'), $env:ProgramFiles, [Environment]::GetEnvironmentVariable('ProgramFiles(x86)'))) {
135
+ if (-not $base) { continue }
136
+ $candidate = Join-Path $base ($channel.Folder + '\\bin\\' + $channel.Command)
137
+ if (Test-Path -LiteralPath $candidate) { $command = $candidate; break }
138
+ }
139
+ }
140
+ if (-not $command) { continue }
141
+ try {
142
+ & $command --install-extension $vsix --force | Out-Host
143
+ if ($LASTEXITCODE -ne 0) { throw ('\u5B89\u88C5\u547D\u4EE4\u9000\u51FA\u7801\uFF1A' + $LASTEXITCODE) }
144
+ $extensions = & $command --list-extensions --show-versions
145
+ if ($LASTEXITCODE -ne 0) { throw ('\u6269\u5C55\u5217\u8868\u547D\u4EE4\u9000\u51FA\u7801\uFF1A' + $LASTEXITCODE) }
146
+ $match = $extensions | Where-Object { $_ -like '${extensionId}@*' } | Select-Object -First 1
147
+ if (-not $match${version ? ` -or $match -ne ${psQuote(`${extensionId}@${version}`)}` : ""}) { throw '\u5B89\u88C5\u540E\u672A\u627E\u5230\u6307\u5B9A\u7248\u672C\u7684 Web Terminal \u6269\u5C55' }
148
+ $results += @{ target = ('Windows ' + $channel.Name); extension = $match }
149
+ } catch { $failures += ('Windows ' + $channel.Name + ': ' + $_.Exception.Message) }
150
+ }
151
+ Write-Output ('WEB_TERMINAL_INSTALL::' + (ConvertTo-Json -Compress -Depth 4 -InputObject @{ installed = @($results); failures = @($failures) }))
152
+ } catch { Write-Error $_ -ErrorAction Continue; exit 1 }
153
+ finally { if ($temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } }
154
+ `;
155
+ const powershell = wsl ? "/mnt/c/Windows/System32/WindowsPowerShell/v1.0/powershell.exe" : "powershell.exe";
156
+ const { stdout } = await execute(powershell, ["-NoProfile", "-NonInteractive", "-EncodedCommand", Buffer.from(script, "utf16le").toString("base64")], {
157
+ timeout: 18e4,
158
+ windowsHide: true,
159
+ maxBuffer: 4 * 1024 * 1024
160
+ });
161
+ const marker = stdout.split(/\r?\n/).find((line) => line.startsWith("WEB_TERMINAL_INSTALL::"));
162
+ if (!marker) throw new Error(`Windows \u5B89\u88C5\u6CA1\u6709\u8FD4\u56DE\u9A8C\u8BC1\u7ED3\u679C\uFF1A
163
+ ${stdout.trim()}`);
164
+ const result = JSON.parse(marker.slice("WEB_TERMINAL_INSTALL::".length));
165
+ for (const installed of result.installed) verifyExtension(installed.extension, version);
166
+ if (result.failures.length) throw new Error([...result.installed.map((item) => `${item.target} \u5DF2\u5B89\u88C5 ${item.extension}`), ...result.failures].join("\n"));
167
+ return result.installed;
168
+ }
169
+ async function installVSCodeExtension(options = {}) {
170
+ const path = options.vsixPath ? resolve(options.vsixPath) : fileURLToPath(new URL("../release/web-terminal.vsix", import.meta.url));
171
+ if (!await exists(path)) throw new Error(`\u672A\u627E\u5230 VSIX\uFF1A${path}\u3002\u672C\u5730\u5F00\u53D1\u8BF7\u8FD0\u884C pnpm package:vscode\uFF0C\u6216\u7528 --vsix \u6307\u5B9A\u6587\u4EF6\u3002`);
172
+ const version = options.vsixPath ? void 0 : serviceVersion;
173
+ const wsl = await isWsl();
174
+ const installed = [];
175
+ const failures = [];
176
+ const record = (result) => {
177
+ installed.push(result);
178
+ options.onInstalled?.(result);
179
+ };
180
+ if (process.platform !== "win32") {
181
+ for (const launcher of await localLaunchers(options, wsl)) {
182
+ try {
183
+ await invoke(launcher, ["--install-extension", path, "--force"]);
184
+ const extension = verifyExtension(await invoke(launcher, ["--list-extensions", "--show-versions"]), version);
185
+ record({ target: launcher.label, extension });
186
+ } catch (error) {
187
+ failures.push(`${launcher.label}\uFF1A${errorDetail(error)}`);
188
+ }
189
+ }
190
+ }
191
+ if (process.platform === "win32" || (options.installWindowsHost ?? wsl)) {
192
+ try {
193
+ for (const result of await installWindows(path, wsl, version)) record(result);
194
+ } catch (error) {
195
+ failures.push(errorDetail(error));
196
+ }
197
+ }
198
+ if (failures.length) throw new Error(`\u63D2\u4EF6\u5B89\u88C5\u672A\u5168\u90E8\u5B8C\u6210\uFF1A
199
+ ${failures.join("\n")}`);
200
+ if (!installed.length) throw new Error("\u672A\u627E\u5230\u53EF\u7528\u7684 VS Code\u3002\u8BF7\u5B89\u88C5 VS Code \u5E76\u5C06 code \u52A0\u5165 PATH\uFF1BWSL \u6269\u5C55\u5BBF\u4E3B\u9700\u5148\u7528 Remote WSL \u8FDE\u63A5\u4E00\u6B21\u3002");
201
+ return installed;
202
+ }
203
+ function errorDetail(error) {
204
+ const failure = error;
205
+ return [error instanceof Error ? error.message : String(error), failure.stderr?.trim(), failure.stdout?.trim()].filter(Boolean).join("\n");
206
+ }
207
+ export {
208
+ findServerLauncher,
209
+ installVSCodeExtension
210
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dadigua/web-terminal",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "type": "module",
5
5
  "scripts": {
6
6
  "dev": "tsx src/server/index.ts --dev",
@@ -10,10 +10,11 @@
10
10
  "check": "tsc --noEmit",
11
11
  "build:clients": "tsx scripts/build-clients.ts",
12
12
  "package:vscode": "pnpm build:clients && tsx scripts/package-vscode.ts",
13
+ "install:vscode": "pnpm package:vscode && tsx src/cli.ts install-vscode",
13
14
  "electron": "pnpm build:clients && electron dist-clients/electron",
14
15
  "package:electron:win": "pnpm build:clients && tsx scripts/package-electron.ts --win",
15
16
  "package:electron:linux": "pnpm build:clients && tsx scripts/package-electron.ts --linux",
16
- "prepack": "pnpm build"
17
+ "prepack": "pnpm build && pnpm package:vscode"
17
18
  },
18
19
  "dependencies": {
19
20
  "@xterm/addon-fit": "^0.11.0",
@@ -49,7 +50,7 @@
49
50
  "engines": {
50
51
  "node": ">=22"
51
52
  },
52
- "packageManager": "pnpm@11.12.0",
53
+ "packageManager": "pnpm@11.28.2",
53
54
  "bin": {
54
55
  "web-terminal": "bin/web-terminal.mjs"
55
56
  },
@@ -57,6 +58,7 @@
57
58
  "bin/",
58
59
  "dist-server/",
59
60
  "dist/",
61
+ "release/web-terminal.vsix",
60
62
  "config.example.yaml",
61
63
  "README.md",
62
64
  "API.md"
Binary file