@lengmoxxl/sdk 0.1.4 → 0.1.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 CHANGED
@@ -1,54 +1,152 @@
1
- # @lengmoxxl/sdk
2
- The client SDK for [WangCai](https://github.com/lengmoXXL/WangCai), the terminal app. It reaches a machine, the one
3
- the app runs on or one over SSH, and drives its terminals, files and subprocesses through a single connection.
1
+ # @lengmoxxl/sdk:接口参考
4
2
 
5
- A WangCai plugin compiles against this package for the types it uses; the machines belong to the app, which keeps
6
- one connection per machine and hands it to every plugin that asks for one.
3
+ 本参考对应 SDK `0.1.5`:连接本机或 SSH 机器上的旺财后台进程,提供终端、文件与子进程
4
+ 操作。插件入口与生命周期见 [插件开发手册](../docs/plugins.md),进程关系见
5
+ [架构说明](../docs/architecture.md)。
7
6
 
8
- ## Install
7
+ ## 插件上下文
9
8
 
10
- ```
11
- npm install @lengmoxxl/sdk
12
- ```
9
+ | 成员 | 说明 |
10
+ | --- | --- |
11
+ | `global` | 公共 `Bus`,跨插件与前后端通信 |
12
+ | `ui.handle<T>(method, handler)` | 后端注册方法,返回取消注册的 `Dispose` |
13
+ | `ui.publish(topic, data)` | 后端向本插件 UI 发事件,对应前端 `ui.subscribe` |
14
+ | `ui.subscribe<T>(topic, callback)` | 前端订阅本插件后端事件,返回取消订阅的函数 |
15
+ | `ui.request<T>(method, params?)` | 前端调用本插件后端方法,返回 `Promise<T>` |
16
+ | `connect(machine, signal?)` | 后端借用机器连接:`{ host?: string }`,可带 `AbortSignal` |
17
+ | `host.dataDirectory` / `host.config` | 插件私有数据目录 / 本插件设置 |
18
+ | `host.tabs(options)` / `host.open(url)` | 前端开侧栏标签页 / 用系统浏览器打开 http、https |
19
+ | `host.preview` | `{ url, message }`,HTML 预览 frame 的地址与握手消息 |
13
20
 
14
- `ws` is the only runtime dependency. The bundled `dist/index.cjs` reads it at require time.
21
+ 请求与事件传可结构化克隆的数据,不传函数、DOM、Pty 或连接实例。
15
22
 
16
- ## Use
23
+ `ui.handle` 注册的方法名不能含 `:`;`ui.publish` 与 `ui.subscribe` 的主题由宿主加插件 id 前缀,
24
+ `global` 用原始主题,自定义主题要自己加命名空间(如 `directory-demo:opened`)。
17
25
 
18
- ```ts
19
- import { openMachine } from '@lengmoxxl/sdk';
26
+ `connect({})` 连接本机,`connect({ host })` 连接 SSH。
20
27
 
21
- const connection = await openMachine({ type: 'local', binary: '/path/to/wangcai' });
28
+ 宿主为相同机器复用连接并计数,每次取得连接对应一次 `disconnect()`。
22
29
 
23
- const sessions = await connection.pty.list();
24
- const session = await connection.pty.create({ rows: 24, cols: 80 }, '/repo');
25
- const terminal = await connection.pty.attach(session.id);
26
- terminal.onData(({ data }) => process.stdout.write(data));
27
- await terminal.write('git status\n');
30
+ `Dispose = () => void | Promise<void>`,卸载时清理资源,不要关闭用户仍要保留的 PTY。
28
31
 
29
- const { stdout } = await connection.subprocess.exec('git', ['status'], { cwd: '/repo' });
30
- connection.disconnect();
31
- ```
32
+ `Bus`:`publish(topic, data): Promise<void>`、`subscribe<T>(topic, callback): () => void`。
32
33
 
33
- `openMachine` returns a `MachineConnection`; every call opens its own. Sharing one belongs to the caller: a
34
- connection is reference counted, so callers that hand the same one around keep it alive until the last
35
- `disconnect()`. `openMachine({ type: 'ssh', host, agent })` calls `agent(host)` before it connects: putting
36
- the carrier agent on the host belongs to the app that embeds the SDK.
34
+ `host.dataDirectory` 默认 `~/.local/share/wangcai/data/<plugin-id>`。
37
35
 
38
- - `connection.pty` - `list`, `cwd`, `create`, `attach`, `close`
39
- - `connection.fs` - `stat`, `readDirectory`, `readFile`
40
- - `connection.subprocess` - `exec`, which returns `{ stdout, stderr, code }` as `Uint8Array`
41
- - `connection.onState` - connection status and the sessions it can see
36
+ `host.config` 是浅合并,避免把自定义字段命名为 `theme`,配色键见
37
+ [README 主题参数](../README.md#主题参数)。
42
38
 
43
- The host-side context a plugin is activated with (`MainContext`, `UiContext`, `Bus`, and the workspace protocol
44
- types) lives in the `@lengmoxxl/sdk/channel` entry. That entry is types only.
39
+ ## 标签页
45
40
 
46
- ## Build
41
+ `host.tabs({ id, title, tooltip?, workspaceId?, onClose?, mount })`:`id`、`title` 必填,
42
+ `tooltip?` 是标签上的悬停提示(缺省用 `title`),`workspaceId` 缺省用当前工作区;
43
+ `mount(container)` 返回的 `TabContent.dispose` 必填,`onSelect?` 在选中、工作区刷新或侧栏重新显示时
44
+ 都可能调用,`onClose?` 在移除标签页时先于 `dispose` 调用。
47
45
 
46
+ 相同插件 id、工作区 id、标签 id 再次调用 `host.tabs` 会复用并选中;尚无工作区时打开的标签页
47
+ 会在第一个工作区激活时绑定到它。
48
+
49
+ `TabRecord = { plugin, id, workspaceId? }` 只保存身份,插件的 `open(context, record)` 据此恢复
50
+ 内容,资源必须仍在。
51
+
52
+ ## 预览
53
+
54
+ 用 `host.preview.url` 建 iframe,设 `sandbox="allow-scripts"`,不要加
55
+ `allow-same-origin`;收到来源为该 frame、`event.data === host.preview.message` 的消息后
56
+ `postMessage(html, '*')`。预览会执行其中脚本。
57
+
58
+ ## 插件配置
59
+
60
+ 插件 `config` 是嵌套对象,叶子声明 `type` 与可选 `default`;宿主只保留 schema 声明的键,
61
+ 缺失或类型不符时取 default,`array` 内部条目由插件自己验证。
62
+
63
+ ## 工作区协议
64
+
65
+ 放入 init.ts `workspaces` 的插件用 `ui.handle` 提供:`workspaces()`(`WorkspaceRow[]`,
66
+ 按显示顺序)、`workspace-menu()`(`WorkspaceMenuItem[]`)、`workspace-create({ key })`
67
+ (返回 `{ id }`)、`workspace-select({ id })`、`workspace-close({ id })`、
68
+ `workspace-move({ id, before? })`(`before` 是目标工作区 id,不是索引;省略为末尾,且只
69
+ 允许同一 provider 内拖动)。
70
+
71
+ provider 要保证工作区 id 在所有 provider 的行中唯一识别;`error` 由 provider 给出并在重试成功后清除,
72
+ 宿主不禁用带 `error` 的菜单项。
73
+
74
+ 约定的公共主题:`workspaces`(行变化,App 重新请求列表)、`workspace:active`
75
+ (`WorkspaceActive | null`,provider 发布)、`workspace:query`(视图请求 provider 再发布
76
+ 当前工作区)、`onclick`(`FileClick`)。
77
+
78
+ 字段类型见 SDK 导出的 `WorkspaceRow`、`WorkspaceMenuItem`、`Machine`、`WorkspaceActive`
79
+ 与 `FileClick`,定义在 `sdk/types.ts`。
80
+
81
+ ## 机器、终端与子进程
82
+
83
+ `MachineConnection`:`ready`(首次连接就绪)、`state`、`port`(本地 loopback 端口,SSH 时
84
+ 为转发端口)、`onState(callback)`、`retain()`(连接层使用)、`disconnect()`,以及 `pty`、`fs`、
85
+ `subprocess`。
86
+
87
+ `MachineState` 含 `status`、`error?`、`sessions`、`generation`。
88
+
89
+ 断线会自动重连并重新 attach 已挂载的终端与进程,失败的请求与输入不自动重放,后台进程重启时旧 Pty 与进程失效。
90
+
91
+ `connection.pty`:`list()`、`cwd(id)`、`create(size?, cwd?)`(默认 24 行 80 列;cwd 用绝对目录,
92
+ 省略时在后台进程的 HOME 下启动)、`attach(id)`(复用句柄)、`close(id)`(结束 shell 与后台子孙进程)。
93
+
94
+ `Size` 用整数:rows 2–300,cols 2–500。
95
+
96
+ `Session` 有 `id, title`、`pid, rows, cols`、`exit_code: number | null`。
97
+
98
+ `Pty`:`id`、`onSnapshot(callback)`、`onData(callback)`、`write(data)`、`resize(size)`、
99
+ `detach()`(停止挂载,不关闭 shell)。
100
+
101
+ `Output = { data: Uint8Array; seq: number }`,`Snapshot` 额外有 `rows, cols`。
102
+
103
+ 字节含终端控制序列,交给终端模拟器,不要把快照当键盘输入重发。
104
+
105
+ `receive` 与 `release(error?)` 由连接层使用。
106
+
107
+ `connection.fs`:`stat(path)` 返回 `{ isDirectory: boolean } | null`、`readDirectory(path)` 返回
108
+ `DirectoryEntry[]`(`{ name, isDirectory }`)、`readFile(path)` 返回原始字节。
109
+
110
+ 路径为绝对路径,`readFile` 上限 16 MiB;SDK 不提供写入、删除或上传接口。
111
+
112
+ `connection.subprocess.exec(program, args, options)`:`args` 是独立 argv(不经 shell 展开),
113
+ `options.cwd` 是绝对工作目录,`options.env` 覆盖继承的环境变量;返回
114
+ `{ stdout: Uint8Array; stderr: Uint8Array; code: number }`。非零退出码仍返回结果,无法启动、
115
+ 超时、输出超限或被信号终止时 reject;stdin 关闭,长时间或交互用 `subprocess.spawn` 或 PTY。
116
+
117
+ 执行上限 15 秒,SDK 等待响应上限 20 秒,stdout 与 stderr 各最多 8 MiB,断线取消未完成的 exec。
118
+
119
+ `connection.subprocess.spawn(program, args, options)`(`options` 同 `exec`)启动一个
120
+ 长期存活的进程,stdin、stdout、stderr 都是管道,返回 `ProcessInfo = { id, pid, exit_code }`;
121
+ `attach(id)` 复用句柄(未 attach 前不接收输出),`close(id)` 结束进程及其进程组;进程归后台进程所有,不 `close` 就一直留着。
122
+
123
+ `Process`:`id`、`onData(callback)`、`onExit(callback)`、`write(data)`。
124
+
125
+ `ProcessOutput = { data: Uint8Array; seq: number; stream: 'stdout' | 'stderr' }`,两路输出各自按序到达,
126
+ `seq` 单调递增,跨两路的先后以 `seq` 为准。
127
+
128
+ `onExit` 给出退出码(被信号结束时按 shell 的 128+signal),节点来不及投递时给出 `undefined`,此时
129
+ 输出流已不可信,进程可能仍在运行,请重新 `spawn`。
130
+
131
+ `write` 按 32 KiB 分片、按序应用,进程退出后 reject;先 `attach` 再写。
132
+
133
+ `receive` 与 `release(error?)` 由连接层使用。
134
+
135
+ ## 独立 Node 客户端
136
+
137
+ App 插件用 `context.connect`;插件之外才调用 `openMachine`:
138
+
139
+ ```ts
140
+ import { openMachine } from '@lengmoxxl/sdk';
141
+
142
+ const connection = await openMachine({ type: 'local', binary: '/path/to/wangcai' });
143
+ await connection.fs.readDirectory('/');
144
+ connection.disconnect();
48
145
  ```
49
- npm run build
50
- ```
51
146
 
52
- From the repository root (the TypeScript and esbuild devDependencies live in the workspace root): esbuild bundles
53
- `index.ts` into `dist/index.cjs` with `ws` left external, then `tsc` emits the declarations. `npm publish` and
54
- `npm pack` run it for you.
147
+ `openMachine` 返回 `Promise<MachineConnection>`。`type` 为 `'local'` 或 `'ssh'`;local 可给
148
+ `binary`,ssh 必须给 `host`(系统 ssh 目标或别名)与可选 `agent`(`InstallAgent`),两者
149
+ 都可给 `signal`。不传 `agent` 时 SDK 不下载二进制;连接用 BatchMode SSH。
150
+
151
+ `agentCommand(command)` 返回拼接后的 shell 命令:在 PATH 前加入远端 `$HOME/.local/bin`,
152
+ 并携带当前进程的 WANGCAI_HOME(会转义;command 本身不转义,只传可信命令)。
@@ -1,9 +1,11 @@
1
- import type { ConnectionOptions, MachineState, Session, Size, DirectoryEntry, ExecOptions, ExecResult } from './types';
1
+ import type { ConnectionOptions, MachineState, Session, Size, DirectoryEntry, ExecOptions, ExecResult, ProcessInfo } from './types';
2
+ import { Process } from './process';
2
3
  import { Pty } from './pty';
3
4
  export declare class MachineConnection {
4
5
  private options;
5
6
  private listeners;
6
7
  private terminals;
8
+ private processes;
7
9
  port?: number;
8
10
  private instance?;
9
11
  readonly ready: Promise<void>;
@@ -42,6 +44,9 @@ export declare class MachineConnection {
42
44
  };
43
45
  subprocess: {
44
46
  exec: (program: string, args: string[], options: ExecOptions) => Promise<ExecResult>;
47
+ spawn: (program: string, args: string[], options: ExecOptions) => Promise<ProcessInfo>;
48
+ attach: (id: string) => Promise<Process>;
49
+ close: (id: string) => Promise<void>;
45
50
  };
46
51
  private cleanup;
47
52
  private fail;
package/dist/index.cjs CHANGED
@@ -41,6 +41,68 @@ var import_node_net = require("node:net");
41
41
  var import_node_util = require("node:util");
42
42
  var import_ws = __toESM(require("ws"));
43
43
 
44
+ // process.ts
45
+ var CHUNK = 32 * 1024;
46
+ var Process = class {
47
+ constructor(id, request) {
48
+ this.id = id;
49
+ this.request = request;
50
+ }
51
+ sequence = -1;
52
+ error;
53
+ ended = false;
54
+ code;
55
+ buffered = [];
56
+ outputs = /* @__PURE__ */ new Set();
57
+ exits = /* @__PURE__ */ new Set();
58
+ receive(event) {
59
+ if (this.error) return;
60
+ if (event.event === "exit" || event.event === "lost") {
61
+ this.ended = true;
62
+ this.code = event.event === "exit" ? event.code : void 0;
63
+ for (const callback of this.exits) callback(this.code);
64
+ this.exits.clear();
65
+ return;
66
+ }
67
+ if (event.seq <= this.sequence) return;
68
+ this.sequence = event.seq;
69
+ const output = { data: event.data, seq: event.seq, stream: event.event };
70
+ if (!this.outputs.size) this.buffered.push(output);
71
+ for (const callback of this.outputs) callback(output);
72
+ }
73
+ onData(callback) {
74
+ this.outputs.add(callback);
75
+ for (const event of this.buffered) callback(event);
76
+ this.buffered = [];
77
+ return () => {
78
+ this.outputs.delete(callback);
79
+ };
80
+ }
81
+ /** Its exit code, or nothing when the node lost the stream and the process may still be running. */
82
+ onExit(callback) {
83
+ this.exits.add(callback);
84
+ if (this.ended) callback(this.code);
85
+ return () => {
86
+ this.exits.delete(callback);
87
+ };
88
+ }
89
+ async write(data) {
90
+ if (this.error) throw this.error;
91
+ const requests = [];
92
+ for (let offset = 0; offset < data.length; offset += CHUNK) {
93
+ const chunk = Buffer.from(data.subarray(offset, offset + CHUNK)).toString("base64");
94
+ requests.push(this.request("process_write", { session_id: this.id, data: chunk }));
95
+ }
96
+ await Promise.all(requests);
97
+ }
98
+ release(error = new Error("Process detached")) {
99
+ this.error = error;
100
+ this.outputs.clear();
101
+ this.exits.clear();
102
+ this.buffered = [];
103
+ }
104
+ };
105
+
44
106
  // pty.ts
45
107
  var Pty = class {
46
108
  constructor(id, request, remove) {
@@ -132,6 +194,7 @@ var MachineConnection = class {
132
194
  }
133
195
  listeners = /* @__PURE__ */ new Set();
134
196
  terminals = /* @__PURE__ */ new Map();
197
+ processes = /* @__PURE__ */ new Map();
135
198
  // Where the daemon listens: the window reaches it with this.
136
199
  port;
137
200
  instance;
@@ -174,6 +237,8 @@ var MachineConnection = class {
174
237
  this.rejectReady(new Error("Connection cancelled"));
175
238
  for (const terminal of this.terminals.values()) terminal.release();
176
239
  this.terminals.clear();
240
+ for (const process2 of this.processes.values()) process2.release();
241
+ this.processes.clear();
177
242
  this.state.status = "disconnected";
178
243
  this.state.error = void 0;
179
244
  this.publish();
@@ -217,6 +282,26 @@ var MachineConnection = class {
217
282
  exec: async (program, args, options) => {
218
283
  const result = await this.request("exec", { program, args, ...options }, 2e4);
219
284
  return { stdout: Buffer.from(result.stdout, "base64"), stderr: Buffer.from(result.stderr, "base64"), code: result.code };
285
+ },
286
+ spawn: (program, args, options) => this.request("spawn", { program, args, ...options }),
287
+ attach: async (id) => {
288
+ const existing = this.processes.get(id);
289
+ if (existing) return existing;
290
+ const process2 = new Process(id, (op, params) => this.request(op, params));
291
+ this.processes.set(id, process2);
292
+ try {
293
+ await this.request("process_attach", { session_id: id });
294
+ } catch (error) {
295
+ this.processes.delete(id);
296
+ process2.release();
297
+ throw error;
298
+ }
299
+ return process2;
300
+ },
301
+ close: async (id) => {
302
+ await this.request("process_close", { session_id: id });
303
+ this.processes.get(id)?.release();
304
+ this.processes.delete(id);
220
305
  }
221
306
  };
222
307
  cleanup() {
@@ -285,9 +370,12 @@ var MachineConnection = class {
285
370
  if (binary) {
286
371
  const bytes = data;
287
372
  const length = bytes.readUInt32BE(0);
288
- if (length > bytes.length - 4) throw new Error("Invalid terminal packet");
373
+ if (length > bytes.length - 4) throw new Error("Invalid packet");
289
374
  const header = JSON.parse(bytes.subarray(4, 4 + length).toString());
290
- this.terminals.get(header.session_id)?.receive({ ...header, data: bytes.subarray(4 + length) });
375
+ const event = { ...header, data: bytes.subarray(4 + length) };
376
+ const terminal = this.terminals.get(header.session_id);
377
+ if (terminal) terminal.receive(event);
378
+ else this.processes.get(header.session_id)?.receive(event);
291
379
  } else {
292
380
  const message = JSON.parse(data.toString());
293
381
  if (message.id) {
@@ -326,6 +414,8 @@ var MachineConnection = class {
326
414
  if (this.instance && this.instance !== actual.instance_id) {
327
415
  for (const terminal of this.terminals.values()) terminal.release(new Error("Node restarted; terminal no longer exists"));
328
416
  this.terminals.clear();
417
+ for (const process2 of this.processes.values()) process2.release(new Error("Node restarted; process no longer exists"));
418
+ this.processes.clear();
329
419
  }
330
420
  this.instance = actual.instance_id;
331
421
  for (const [id, terminal] of this.terminals) {
@@ -337,6 +427,15 @@ var MachineConnection = class {
337
427
  this.terminals.delete(id);
338
428
  }
339
429
  }
430
+ for (const [id, process2] of this.processes) {
431
+ try {
432
+ await this.request("process_attach", { session_id: id });
433
+ } catch (error) {
434
+ if (!current()) return;
435
+ process2.release(error);
436
+ this.processes.delete(id);
437
+ }
438
+ }
340
439
  if (!current()) return;
341
440
  this.state.status = "connected";
342
441
  this.state.error = void 0;
package/dist/index.d.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  import { MachineConnection } from './connection';
2
2
  import type { ConnectionOptions } from './types';
3
3
  export { agentCommand } from './agent';
4
- export type { ConnectionOptions, FileClick, Machine, MachineState, Session, Size, Output, Snapshot, TerminalEvent, DirectoryEntry, ExecOptions, ExecResult, InstallAgent, Profile, Theme, WorkspaceActive, WorkspaceRow, WorkspaceMenuItem } from './types';
4
+ export type { ConnectionOptions, FileClick, Machine, MachineState, Session, Size, Output, Snapshot, TerminalEvent, DirectoryEntry, ExecOptions, ExecResult, ProcessEvent, ProcessInfo, ProcessOutput, InstallAgent, Profile, Theme, WorkspaceActive, WorkspaceRow, WorkspaceMenuItem } from './types';
5
5
  export type { MachineConnection } from './connection';
6
+ export type { Process } from './process';
6
7
  export type { Pty } from './pty';
7
8
  /** Opens one connection to a machine and waits for it to be ready. */
8
9
  export declare function openMachine(options: ConnectionOptions): Promise<MachineConnection>;
@@ -0,0 +1,19 @@
1
+ import type { ProcessEvent, ProcessOutput } from './types';
2
+ export declare class Process {
3
+ readonly id: string;
4
+ private request;
5
+ private sequence;
6
+ private error?;
7
+ private ended;
8
+ private code?;
9
+ private buffered;
10
+ private outputs;
11
+ private exits;
12
+ constructor(id: string, request: (op: string, params: Record<string, unknown>) => Promise<unknown>);
13
+ receive(event: ProcessEvent): void;
14
+ onData(callback: (event: ProcessOutput) => void): () => void;
15
+ /** Its exit code, or nothing when the node lost the stream and the process may still be running. */
16
+ onExit(callback: (code?: number) => void): () => void;
17
+ write(data: Uint8Array): Promise<void>;
18
+ release(error?: Error): void;
19
+ }
package/dist/types.d.ts CHANGED
@@ -6,7 +6,7 @@ export type InstallAgent = (host: string, signal?: AbortSignal) => Promise<void>
6
6
  *
7
7
  * workspaces() -> WorkspaceRow[] one flat row per workspace, in draw order
8
8
  * workspace-menu() -> WorkspaceMenuItem[] what the menu offers to open; an entry that carries
9
- * an error stays listed but is not clickable
9
+ * an error stays listed with its reason, and a click retries it
10
10
  * workspace-create({ key }) -> { id: string } the id of the workspace it made
11
11
  * workspace-select({ id })
12
12
  * workspace-close({ id })
@@ -88,6 +88,26 @@ export type TerminalEvent = ({
88
88
  } & Output) & {
89
89
  session_id: string;
90
90
  };
91
+ export interface ProcessInfo {
92
+ id: string;
93
+ pid: number;
94
+ exit_code: number | null;
95
+ }
96
+ /** `stream` names the pipe the bytes came from. */
97
+ export interface ProcessOutput extends Output {
98
+ stream: 'stdout' | 'stderr';
99
+ }
100
+ export type ProcessEvent = ({
101
+ event: 'stdout' | 'stderr';
102
+ } & Output | {
103
+ event: 'exit';
104
+ seq: number;
105
+ code: number;
106
+ } | {
107
+ event: 'lost';
108
+ }) & {
109
+ session_id: string;
110
+ };
91
111
  export interface DirectoryEntry {
92
112
  name: string;
93
113
  isDirectory: boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lengmoxxl/sdk",
3
- "version": "0.1.4",
3
+ "version": "0.1.5",
4
4
  "description": "Client SDK for WangCai: connect to a local or remote machine and drive its pty, files and git",
5
5
  "license": "MIT",
6
6
  "repository": {