@devframes/plugin-terminals 0.0.1
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.md +21 -0
- package/bin.mjs +13 -0
- package/dist/cli.d.mts +12 -0
- package/dist/cli.mjs +13 -0
- package/dist/client/index.d.mts +16 -0
- package/dist/client/index.mjs +12095 -0
- package/dist/constants.d.mts +30 -0
- package/dist/constants.mjs +30 -0
- package/dist/index.d.mts +28 -0
- package/dist/index.mjs +3 -0
- package/dist/node/index.d.mts +157 -0
- package/dist/node/index.mjs +580 -0
- package/dist/rpc/index.d.mts +695 -0
- package/dist/rpc/index.mjs +2 -0
- package/dist/rpc-F1ncx7tt.mjs +247 -0
- package/dist/spa/assets/index-Dmm_EwpX.js +38 -0
- package/dist/spa/assets/index-IBTOt_--.css +1 -0
- package/dist/spa/index.html +19 -0
- package/dist/src-Cu5HYNrI.mjs +56 -0
- package/dist/types.d.mts +129 -0
- package/dist/types.mjs +1 -0
- package/dist/vite.d.mts +18 -0
- package/dist/vite.mjs +20 -0
- package/package.json +88 -0
|
@@ -0,0 +1,580 @@
|
|
|
1
|
+
import { HUB_TERMINAL_STREAM_CHANNEL, PRESETS_STATE_KEY, SESSIONS_STATE_KEY, TERMINAL_STREAM_CHANNEL } from "../constants.mjs";
|
|
2
|
+
import { i as diagnostics, n as getTerminalManager, r as setTerminalManager, t as serverFunctions } from "../rpc-F1ncx7tt.mjs";
|
|
3
|
+
import process from "node:process";
|
|
4
|
+
import { nanoid } from "devframe/utils/nanoid";
|
|
5
|
+
import { OSCInspector } from "zigpty/osc";
|
|
6
|
+
import { spawn } from "node:child_process";
|
|
7
|
+
//#region src/node/backend.ts
|
|
8
|
+
/** TERM name handed to the PTY; also used to reject fallback process labels. */
|
|
9
|
+
const PTY_TERM_NAME = "xterm-256color";
|
|
10
|
+
let zigptyPromise;
|
|
11
|
+
/**
|
|
12
|
+
* Lazily load the PTY backend. The import is deferred so the native binding
|
|
13
|
+
* only loads once a terminal actually spawns, and stays a runtime import for
|
|
14
|
+
* bundled hosts (Next/Turbopack). Resolves to `undefined` when the module
|
|
15
|
+
* fails to load, letting interactive sessions degrade to a piped child
|
|
16
|
+
* process.
|
|
17
|
+
*/
|
|
18
|
+
async function loadZigpty() {
|
|
19
|
+
zigptyPromise ??= import("zigpty").then((m) => m, () => void 0);
|
|
20
|
+
return zigptyPromise;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Whether real pseudo-terminals are available in this runtime — i.e. zigpty's
|
|
24
|
+
* native bindings loaded. Without them interactive sessions still run through
|
|
25
|
+
* zigpty's pipe-based emulation, with degraded TUI fidelity.
|
|
26
|
+
*/
|
|
27
|
+
async function isPtyAvailable() {
|
|
28
|
+
return (await loadZigpty())?.hasNative ?? false;
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Spawn an interactive terminal via zigpty. Uses a real PTY when the native
|
|
32
|
+
* bindings are available, and zigpty's pipe-based emulation (line discipline,
|
|
33
|
+
* signal translation, best-effort resize) otherwise — the reported `backend`
|
|
34
|
+
* reflects which one the session got. Returns `undefined` when the module
|
|
35
|
+
* itself is unavailable or spawning throws.
|
|
36
|
+
*/
|
|
37
|
+
async function spawnPty(options) {
|
|
38
|
+
const zigpty = await loadZigpty();
|
|
39
|
+
if (!zigpty) return void 0;
|
|
40
|
+
let proc;
|
|
41
|
+
try {
|
|
42
|
+
proc = zigpty.spawn(options.command, options.args, {
|
|
43
|
+
name: PTY_TERM_NAME,
|
|
44
|
+
cols: options.cols,
|
|
45
|
+
rows: options.rows,
|
|
46
|
+
cwd: options.cwd,
|
|
47
|
+
env: options.env
|
|
48
|
+
});
|
|
49
|
+
} catch (error) {
|
|
50
|
+
diagnostics.DP_TERMINALS_0004({
|
|
51
|
+
command: options.command,
|
|
52
|
+
reason: error instanceof Error ? error.message : String(error)
|
|
53
|
+
}, { method: "warn" });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
backend: zigpty.hasNative ? "pty" : "pipe",
|
|
58
|
+
get pid() {
|
|
59
|
+
return proc.pid;
|
|
60
|
+
},
|
|
61
|
+
write: (data) => {
|
|
62
|
+
try {
|
|
63
|
+
proc.write(data);
|
|
64
|
+
} catch {}
|
|
65
|
+
},
|
|
66
|
+
resize: (cols, rows) => {
|
|
67
|
+
try {
|
|
68
|
+
proc.resize(Math.max(1, cols), Math.max(1, rows));
|
|
69
|
+
} catch {}
|
|
70
|
+
},
|
|
71
|
+
kill: (signal) => {
|
|
72
|
+
try {
|
|
73
|
+
proc.kill(signal);
|
|
74
|
+
} catch {}
|
|
75
|
+
},
|
|
76
|
+
onData: (cb) => {
|
|
77
|
+
proc.onData((data) => {
|
|
78
|
+
cb(typeof data === "string" ? data : data.toString("utf8"));
|
|
79
|
+
});
|
|
80
|
+
},
|
|
81
|
+
onExit: (cb) => proc.onExit((e) => cb(e.exitCode ?? 0)),
|
|
82
|
+
getProcessName: () => {
|
|
83
|
+
try {
|
|
84
|
+
const name = proc.process;
|
|
85
|
+
return name && name !== PTY_TERM_NAME ? name : void 0;
|
|
86
|
+
} catch {
|
|
87
|
+
return;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
/**
|
|
93
|
+
* Spawn a piped child process. Used for readonly sessions and as the last
|
|
94
|
+
* interactive fallback when zigpty is unavailable entirely. stdout/stderr
|
|
95
|
+
* are merged into a single ordered text stream.
|
|
96
|
+
*/
|
|
97
|
+
function spawnPipe(options) {
|
|
98
|
+
const dataCbs = [];
|
|
99
|
+
const exitCbs = [];
|
|
100
|
+
let exited = false;
|
|
101
|
+
const emitData = (data) => {
|
|
102
|
+
for (const cb of dataCbs) cb(data);
|
|
103
|
+
};
|
|
104
|
+
const emitExit = (code) => {
|
|
105
|
+
if (exited) return;
|
|
106
|
+
exited = true;
|
|
107
|
+
for (const cb of exitCbs) cb(code);
|
|
108
|
+
};
|
|
109
|
+
const child = spawn(options.command, options.args, {
|
|
110
|
+
cwd: options.cwd,
|
|
111
|
+
env: options.env,
|
|
112
|
+
stdio: [
|
|
113
|
+
options.input ? "pipe" : "ignore",
|
|
114
|
+
"pipe",
|
|
115
|
+
"pipe"
|
|
116
|
+
],
|
|
117
|
+
windowsHide: true
|
|
118
|
+
});
|
|
119
|
+
child.stdout?.on("data", (chunk) => emitData(chunk.toString("utf8")));
|
|
120
|
+
child.stderr?.on("data", (chunk) => emitData(chunk.toString("utf8")));
|
|
121
|
+
child.on("error", (error) => {
|
|
122
|
+
emitData(`\r\n[failed to start: ${error.message}]\r\n`);
|
|
123
|
+
emitExit(1);
|
|
124
|
+
});
|
|
125
|
+
child.on("exit", (code) => emitExit(code ?? 0));
|
|
126
|
+
return {
|
|
127
|
+
backend: "pipe",
|
|
128
|
+
get pid() {
|
|
129
|
+
return child.pid;
|
|
130
|
+
},
|
|
131
|
+
write: (data) => {
|
|
132
|
+
if (options.input && child.stdin && !child.stdin.destroyed) child.stdin.write(data);
|
|
133
|
+
},
|
|
134
|
+
resize: () => {},
|
|
135
|
+
kill: (signal) => {
|
|
136
|
+
try {
|
|
137
|
+
child.kill(signal ?? "SIGTERM");
|
|
138
|
+
} catch {}
|
|
139
|
+
},
|
|
140
|
+
onData: (cb) => dataCbs.push(cb),
|
|
141
|
+
onExit: (cb) => exitCbs.push(cb)
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* Spawn the most capable backend for the requested interaction. Interactive
|
|
146
|
+
* sessions prefer a real PTY (for TUIs) and degrade through zigpty's pipe
|
|
147
|
+
* emulation; readonly sessions use a piped child process.
|
|
148
|
+
*/
|
|
149
|
+
async function spawnBackend(options, preferPty) {
|
|
150
|
+
if (preferPty) {
|
|
151
|
+
if (!await isPtyAvailable()) diagnostics.DP_TERMINALS_0005({}, { method: "warn" });
|
|
152
|
+
const pty = await spawnPty(options);
|
|
153
|
+
if (pty) return pty;
|
|
154
|
+
}
|
|
155
|
+
return spawnPipe(options);
|
|
156
|
+
}
|
|
157
|
+
//#endregion
|
|
158
|
+
//#region src/node/manager.ts
|
|
159
|
+
/** How often a PTY session's foreground process name is polled. */
|
|
160
|
+
const PROCESS_POLL_INTERVAL = 1e3;
|
|
161
|
+
/** Map the plugin's session status onto the hub's coarser status set. */
|
|
162
|
+
const HUB_STATUS = {
|
|
163
|
+
running: "running",
|
|
164
|
+
exited: "stopped",
|
|
165
|
+
error: "error"
|
|
166
|
+
};
|
|
167
|
+
/**
|
|
168
|
+
* Normalize a hub dock icon (`ph:code-duotone`, or a light/dark pair) to the
|
|
169
|
+
* UnoCSS `preset-icons` class the client renders (`i-ph-code-duotone`). The
|
|
170
|
+
* client can only render icons the SPA's UnoCSS build statically emitted — see
|
|
171
|
+
* the safelist in `uno.config.ts` — so unknown icons resolve to `undefined`.
|
|
172
|
+
*/
|
|
173
|
+
function toIconClass(icon) {
|
|
174
|
+
const raw = typeof icon === "string" ? icon : icon?.light;
|
|
175
|
+
if (!raw) return void 0;
|
|
176
|
+
return raw.startsWith("i-") ? raw : `i-${raw.replace(":", "-")}`;
|
|
177
|
+
}
|
|
178
|
+
function defaultShell() {
|
|
179
|
+
if (process.platform === "win32") return process.env.COMSPEC || "powershell.exe";
|
|
180
|
+
return process.env.SHELL || "bash";
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Owns terminal session lifecycle: spawns PTY / piped backends, streams
|
|
184
|
+
* their output over the `devframes-plugin-terminals:output` channel (one
|
|
185
|
+
* stream per session, stable for the session's whole life so restarts reuse
|
|
186
|
+
* the same id), and mirrors a serializable session list into shared state.
|
|
187
|
+
*/
|
|
188
|
+
var TerminalManager = class {
|
|
189
|
+
ctx;
|
|
190
|
+
options;
|
|
191
|
+
shell;
|
|
192
|
+
shellArgs;
|
|
193
|
+
defaultCwd;
|
|
194
|
+
defaultMode;
|
|
195
|
+
allowArbitraryCommands;
|
|
196
|
+
presets;
|
|
197
|
+
channel;
|
|
198
|
+
sessionsState;
|
|
199
|
+
sessions = /* @__PURE__ */ new Map();
|
|
200
|
+
ptyAvailable = false;
|
|
201
|
+
/** Session ids this manager has registered into the hub (when hubbed). */
|
|
202
|
+
hubOwned = /* @__PURE__ */ new Set();
|
|
203
|
+
constructor(ctx, options = {}) {
|
|
204
|
+
this.ctx = ctx;
|
|
205
|
+
this.options = options;
|
|
206
|
+
this.shell = options.shell ?? defaultShell();
|
|
207
|
+
this.shellArgs = options.shellArgs ?? [];
|
|
208
|
+
this.defaultCwd = options.cwd ?? ctx.cwd;
|
|
209
|
+
this.defaultMode = options.defaultMode ?? "interactive";
|
|
210
|
+
this.allowArbitraryCommands = options.allowArbitraryCommands ?? false;
|
|
211
|
+
this.presets = options.presets ?? [];
|
|
212
|
+
this.channel = ctx.rpc.streaming.create(TERMINAL_STREAM_CHANNEL, { replayWindow: options.scrollback ?? 5e3 });
|
|
213
|
+
}
|
|
214
|
+
/** Resolve shared state, probe the PTY backend, publish the preset catalog. */
|
|
215
|
+
async init() {
|
|
216
|
+
if (this.sessionsState) return;
|
|
217
|
+
this.ptyAvailable = await isPtyAvailable();
|
|
218
|
+
this.sessionsState = await this.ctx.rpc.sharedState.get(SESSIONS_STATE_KEY, { initialValue: { sessions: [] } });
|
|
219
|
+
(await this.ctx.rpc.sharedState.get(PRESETS_STATE_KEY, { initialValue: { presets: [] } })).mutate((draft) => {
|
|
220
|
+
draft.presets = this.presets.map((p) => ({
|
|
221
|
+
id: p.id,
|
|
222
|
+
title: p.title,
|
|
223
|
+
command: p.command,
|
|
224
|
+
args: p.args ?? [],
|
|
225
|
+
mode: p.mode ?? "readonly",
|
|
226
|
+
icon: p.icon
|
|
227
|
+
}));
|
|
228
|
+
});
|
|
229
|
+
this.hubTerminals()?.events?.on("terminal:session:updated", (session) => {
|
|
230
|
+
if (!this.sessions.has(session.id)) this.refreshSessionsState();
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
/** The hub's terminals subsystem when mounted in a hub, else undefined. */
|
|
234
|
+
hubTerminals() {
|
|
235
|
+
return this.ctx.terminals;
|
|
236
|
+
}
|
|
237
|
+
list() {
|
|
238
|
+
const own = Array.from(this.sessions.values()).map((s) => ({ ...s.info }));
|
|
239
|
+
const hub = this.hubTerminals();
|
|
240
|
+
if (!hub?.sessions) return own;
|
|
241
|
+
const foreign = [];
|
|
242
|
+
for (const session of hub.sessions.values()) {
|
|
243
|
+
if (this.sessions.has(session.id)) continue;
|
|
244
|
+
foreign.push({
|
|
245
|
+
id: session.id,
|
|
246
|
+
title: session.title,
|
|
247
|
+
mode: session.interactive ? "interactive" : "readonly",
|
|
248
|
+
status: session.status === "stopped" ? "exited" : session.status,
|
|
249
|
+
backend: session.interactive ? "pty" : "pipe",
|
|
250
|
+
command: "",
|
|
251
|
+
args: [],
|
|
252
|
+
cwd: "",
|
|
253
|
+
cols: 80,
|
|
254
|
+
rows: 24,
|
|
255
|
+
createdAt: 0,
|
|
256
|
+
icon: toIconClass(session.icon),
|
|
257
|
+
channel: HUB_TERMINAL_STREAM_CHANNEL
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
return [...own, ...foreign];
|
|
261
|
+
}
|
|
262
|
+
getPresets() {
|
|
263
|
+
return this.presets.map((p) => ({ ...p }));
|
|
264
|
+
}
|
|
265
|
+
buildEnv(extra) {
|
|
266
|
+
const base = {};
|
|
267
|
+
for (const [k, v] of Object.entries(process.env)) if (v !== void 0) base[k] = v;
|
|
268
|
+
return {
|
|
269
|
+
...base,
|
|
270
|
+
TERM: "xterm-256color",
|
|
271
|
+
COLORTERM: "truecolor",
|
|
272
|
+
FORCE_COLOR: "1",
|
|
273
|
+
...this.options.env,
|
|
274
|
+
...extra
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
resolveSpawn(req) {
|
|
278
|
+
const cols = req.cols ?? 80;
|
|
279
|
+
const rows = req.rows ?? 24;
|
|
280
|
+
if (req.presetId) {
|
|
281
|
+
const preset = this.presets.find((p) => p.id === req.presetId);
|
|
282
|
+
if (!preset) throw diagnostics.DP_TERMINALS_0006({ id: req.presetId });
|
|
283
|
+
return {
|
|
284
|
+
command: preset.command,
|
|
285
|
+
args: req.args ?? preset.args ?? [],
|
|
286
|
+
cwd: req.cwd ?? preset.cwd ?? this.defaultCwd,
|
|
287
|
+
mode: req.mode ?? preset.mode ?? "readonly",
|
|
288
|
+
env: this.buildEnv({
|
|
289
|
+
...preset.env,
|
|
290
|
+
...req.env
|
|
291
|
+
}),
|
|
292
|
+
title: req.title ?? preset.title,
|
|
293
|
+
cols,
|
|
294
|
+
rows,
|
|
295
|
+
presetId: preset.id
|
|
296
|
+
};
|
|
297
|
+
}
|
|
298
|
+
if (req.command) {
|
|
299
|
+
if (!this.allowArbitraryCommands && req.command !== this.shell) throw diagnostics.DP_TERMINALS_0002({ command: req.command });
|
|
300
|
+
return {
|
|
301
|
+
command: req.command,
|
|
302
|
+
args: req.args ?? [],
|
|
303
|
+
cwd: req.cwd ?? this.defaultCwd,
|
|
304
|
+
mode: req.mode ?? "interactive",
|
|
305
|
+
env: this.buildEnv(req.env),
|
|
306
|
+
title: req.title ?? req.command,
|
|
307
|
+
cols,
|
|
308
|
+
rows
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
return {
|
|
312
|
+
command: this.shell,
|
|
313
|
+
args: this.shellArgs,
|
|
314
|
+
cwd: req.cwd ?? this.defaultCwd,
|
|
315
|
+
mode: req.mode ?? this.defaultMode,
|
|
316
|
+
env: this.buildEnv(req.env),
|
|
317
|
+
title: req.title ?? "Shell",
|
|
318
|
+
cols,
|
|
319
|
+
rows
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
/**
|
|
323
|
+
* Spawn a session and return its descriptor immediately. The OS process
|
|
324
|
+
* is launched in the background and streams into the session's stream as
|
|
325
|
+
* soon as it produces output; clients can subscribe by id right away.
|
|
326
|
+
*/
|
|
327
|
+
spawn(req = {}) {
|
|
328
|
+
const spawn = this.resolveSpawn(req);
|
|
329
|
+
const id = nanoid();
|
|
330
|
+
const usePty = spawn.mode === "interactive" && this.ptyAvailable;
|
|
331
|
+
const sink = this.channel.start({ id });
|
|
332
|
+
const info = {
|
|
333
|
+
id,
|
|
334
|
+
title: spawn.title,
|
|
335
|
+
mode: spawn.mode,
|
|
336
|
+
status: "running",
|
|
337
|
+
backend: usePty ? "pty" : "pipe",
|
|
338
|
+
command: spawn.command,
|
|
339
|
+
args: spawn.args,
|
|
340
|
+
cwd: spawn.cwd,
|
|
341
|
+
cols: spawn.cols,
|
|
342
|
+
rows: spawn.rows,
|
|
343
|
+
presetId: spawn.presetId,
|
|
344
|
+
createdAt: Date.now()
|
|
345
|
+
};
|
|
346
|
+
const session = {
|
|
347
|
+
info,
|
|
348
|
+
sink,
|
|
349
|
+
spawn
|
|
350
|
+
};
|
|
351
|
+
this.sessions.set(id, session);
|
|
352
|
+
this.launch(session);
|
|
353
|
+
this.publish();
|
|
354
|
+
return { ...info };
|
|
355
|
+
}
|
|
356
|
+
async launch(session) {
|
|
357
|
+
const { spawn, sink } = session;
|
|
358
|
+
let proc;
|
|
359
|
+
try {
|
|
360
|
+
proc = await spawnBackend({
|
|
361
|
+
command: spawn.command,
|
|
362
|
+
args: spawn.args,
|
|
363
|
+
cwd: spawn.cwd,
|
|
364
|
+
env: spawn.env,
|
|
365
|
+
cols: spawn.cols,
|
|
366
|
+
rows: spawn.rows,
|
|
367
|
+
input: spawn.mode === "interactive"
|
|
368
|
+
}, spawn.mode === "interactive");
|
|
369
|
+
} catch (error) {
|
|
370
|
+
const reason = error instanceof Error ? error.message : String(error);
|
|
371
|
+
diagnostics.DP_TERMINALS_0004({
|
|
372
|
+
command: spawn.command,
|
|
373
|
+
reason
|
|
374
|
+
}, { method: "warn" });
|
|
375
|
+
session.info.status = "error";
|
|
376
|
+
session.info.pid = void 0;
|
|
377
|
+
if (!sink.closed) sink.write(`\r\n\x1B[31m[failed to start: ${reason}]\x1B[0m\r\n`);
|
|
378
|
+
this.publish();
|
|
379
|
+
return;
|
|
380
|
+
}
|
|
381
|
+
session.proc = proc;
|
|
382
|
+
session.info.status = "running";
|
|
383
|
+
session.info.exitCode = void 0;
|
|
384
|
+
session.info.backend = proc.backend;
|
|
385
|
+
session.info.pid = proc.pid;
|
|
386
|
+
session.osc?.dispose();
|
|
387
|
+
session.info.termTitle = void 0;
|
|
388
|
+
session.info.termCwd = void 0;
|
|
389
|
+
const osc = new OSCInspector();
|
|
390
|
+
session.osc = osc;
|
|
391
|
+
osc.onStateChange((state) => {
|
|
392
|
+
const termTitle = state.title?.trim() || void 0;
|
|
393
|
+
const termCwd = state.cwd?.path;
|
|
394
|
+
if (termTitle !== session.info.termTitle || termCwd !== session.info.termCwd) {
|
|
395
|
+
session.info.termTitle = termTitle;
|
|
396
|
+
session.info.termCwd = termCwd;
|
|
397
|
+
this.publish();
|
|
398
|
+
}
|
|
399
|
+
});
|
|
400
|
+
proc.onData((data) => {
|
|
401
|
+
osc.feed(data);
|
|
402
|
+
if (!sink.closed) sink.write(data);
|
|
403
|
+
});
|
|
404
|
+
proc.onExit((code) => {
|
|
405
|
+
if (session.proc !== proc) return;
|
|
406
|
+
this.stopProcessPoll(session);
|
|
407
|
+
session.osc?.dispose();
|
|
408
|
+
session.osc = void 0;
|
|
409
|
+
session.info.status = code === 0 ? "exited" : "error";
|
|
410
|
+
session.info.exitCode = code;
|
|
411
|
+
session.info.pid = void 0;
|
|
412
|
+
session.info.processName = void 0;
|
|
413
|
+
if (!sink.closed) sink.write(`\r\n\x1B[2m[process exited with code ${code}]\x1B[0m\r\n`);
|
|
414
|
+
this.publish();
|
|
415
|
+
});
|
|
416
|
+
this.startProcessPoll(session);
|
|
417
|
+
this.publish();
|
|
418
|
+
}
|
|
419
|
+
/**
|
|
420
|
+
* Poll the PTY foreground process name and reflect changes (e.g. `bash`
|
|
421
|
+
* → `vim` → `bash`) into the session info. The interval is `unref`'d so
|
|
422
|
+
* it never keeps the process alive on its own.
|
|
423
|
+
*/
|
|
424
|
+
startProcessPoll(session) {
|
|
425
|
+
if (!session.proc?.getProcessName) return;
|
|
426
|
+
const poll = () => {
|
|
427
|
+
const name = session.proc?.getProcessName?.();
|
|
428
|
+
if (name && name !== session.info.processName) {
|
|
429
|
+
session.info.processName = name;
|
|
430
|
+
this.publish();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
poll();
|
|
434
|
+
session.pollTimer = setInterval(poll, PROCESS_POLL_INTERVAL);
|
|
435
|
+
session.pollTimer.unref?.();
|
|
436
|
+
}
|
|
437
|
+
stopProcessPoll(session) {
|
|
438
|
+
if (session.pollTimer) {
|
|
439
|
+
clearInterval(session.pollTimer);
|
|
440
|
+
session.pollTimer = void 0;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
write(id, data) {
|
|
444
|
+
const session = this.sessions.get(id);
|
|
445
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
446
|
+
if (session.info.mode !== "interactive") throw diagnostics.DP_TERMINALS_0003({ id });
|
|
447
|
+
if (session.info.status !== "running") return;
|
|
448
|
+
session.proc?.write(data);
|
|
449
|
+
}
|
|
450
|
+
resize(id, cols, rows) {
|
|
451
|
+
const session = this.sessions.get(id);
|
|
452
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
453
|
+
session.info.cols = cols;
|
|
454
|
+
session.info.rows = rows;
|
|
455
|
+
session.spawn.cols = cols;
|
|
456
|
+
session.spawn.rows = rows;
|
|
457
|
+
session.proc?.resize(cols, rows);
|
|
458
|
+
}
|
|
459
|
+
/** Stop the process but keep the session (and its stream) around. */
|
|
460
|
+
terminate(id) {
|
|
461
|
+
const session = this.sessions.get(id);
|
|
462
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
463
|
+
session.proc?.kill();
|
|
464
|
+
}
|
|
465
|
+
/** Set a custom display name. Pass an empty string to clear it. */
|
|
466
|
+
rename(id, title) {
|
|
467
|
+
const session = this.sessions.get(id);
|
|
468
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
469
|
+
const trimmed = title.trim();
|
|
470
|
+
session.info.customTitle = trimmed.length ? trimmed : void 0;
|
|
471
|
+
this.publish();
|
|
472
|
+
}
|
|
473
|
+
/** Restart the session's command in place, reusing the same stream id. */
|
|
474
|
+
restart(id) {
|
|
475
|
+
const session = this.sessions.get(id);
|
|
476
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
477
|
+
const previous = session.proc;
|
|
478
|
+
session.proc = void 0;
|
|
479
|
+
this.stopProcessPoll(session);
|
|
480
|
+
previous?.kill();
|
|
481
|
+
if (!session.sink.closed) session.sink.write("\r\n\x1B[2m[restarting…]\x1B[0m\r\n");
|
|
482
|
+
session.info.status = "running";
|
|
483
|
+
session.info.exitCode = void 0;
|
|
484
|
+
this.launch(session);
|
|
485
|
+
this.publish();
|
|
486
|
+
return { ...session.info };
|
|
487
|
+
}
|
|
488
|
+
/** Kill the process, close the stream, and drop the session. */
|
|
489
|
+
remove(id) {
|
|
490
|
+
const session = this.sessions.get(id);
|
|
491
|
+
if (!session) throw diagnostics.DP_TERMINALS_0001({ id });
|
|
492
|
+
const proc = session.proc;
|
|
493
|
+
session.proc = void 0;
|
|
494
|
+
this.stopProcessPoll(session);
|
|
495
|
+
session.osc?.dispose();
|
|
496
|
+
session.osc = void 0;
|
|
497
|
+
proc?.kill();
|
|
498
|
+
if (!session.sink.closed) session.sink.close();
|
|
499
|
+
this.sessions.delete(id);
|
|
500
|
+
this.publish();
|
|
501
|
+
}
|
|
502
|
+
/** Tear everything down — used on server shutdown and in tests. */
|
|
503
|
+
dispose() {
|
|
504
|
+
for (const session of this.sessions.values()) {
|
|
505
|
+
this.stopProcessPoll(session);
|
|
506
|
+
session.osc?.dispose();
|
|
507
|
+
session.osc = void 0;
|
|
508
|
+
session.proc?.kill();
|
|
509
|
+
if (!session.sink.closed) session.sink.close();
|
|
510
|
+
}
|
|
511
|
+
this.sessions.clear();
|
|
512
|
+
this.publish();
|
|
513
|
+
}
|
|
514
|
+
publish() {
|
|
515
|
+
this.refreshSessionsState();
|
|
516
|
+
this.syncHub();
|
|
517
|
+
}
|
|
518
|
+
/**
|
|
519
|
+
* Push the current session list (own + aggregated hub sessions) into shared
|
|
520
|
+
* state. Kept separate from {@link publish} so the hub-session listener can
|
|
521
|
+
* refresh without re-running {@link syncHub} (which would re-emit hub events
|
|
522
|
+
* and loop).
|
|
523
|
+
*/
|
|
524
|
+
refreshSessionsState() {
|
|
525
|
+
this.sessionsState?.mutate((draft) => {
|
|
526
|
+
draft.sessions = this.list();
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
/**
|
|
530
|
+
* Reflect the live session list into the hub's terminals subsystem when this
|
|
531
|
+
* devframe is mounted in a hub. `ctx.terminals` only exists on a
|
|
532
|
+
* `DevframeHubContext`, so it's accessed by duck-typing — standalone runtimes
|
|
533
|
+
* (CLI / Vite / build) have no such property and skip silently. This is what
|
|
534
|
+
* surfaces the plugin's sessions in the hub's own terminals panel.
|
|
535
|
+
*/
|
|
536
|
+
syncHub() {
|
|
537
|
+
const hub = this.ctx.terminals;
|
|
538
|
+
if (!hub?.register) return;
|
|
539
|
+
const live = /* @__PURE__ */ new Set();
|
|
540
|
+
for (const { info } of this.sessions.values()) {
|
|
541
|
+
live.add(info.id);
|
|
542
|
+
const entry = {
|
|
543
|
+
id: info.id,
|
|
544
|
+
title: info.customTitle || info.termTitle || info.processName || info.title,
|
|
545
|
+
description: `${info.mode} · ${info.backend}${info.pid ? ` · pid ${info.pid}` : ""}`,
|
|
546
|
+
status: HUB_STATUS[info.status] ?? "stopped",
|
|
547
|
+
icon: "ph:terminal-window-duotone"
|
|
548
|
+
};
|
|
549
|
+
if (hub.sessions.has(info.id)) hub.update(entry);
|
|
550
|
+
else {
|
|
551
|
+
hub.register(entry);
|
|
552
|
+
this.hubOwned.add(info.id);
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const id of [...this.hubOwned]) if (!live.has(id)) {
|
|
556
|
+
hub.remove?.({ id });
|
|
557
|
+
this.hubOwned.delete(id);
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
};
|
|
561
|
+
//#endregion
|
|
562
|
+
//#region src/node/index.ts
|
|
563
|
+
/**
|
|
564
|
+
* Wire the terminals subsystem onto a devframe node context: create the
|
|
565
|
+
* {@link TerminalManager}, publish presets + the session list into shared
|
|
566
|
+
* state, and register the control RPC functions. Returns the manager so
|
|
567
|
+
* callers can spawn sessions or dispose it on shutdown.
|
|
568
|
+
*
|
|
569
|
+
* Works in any devframe runtime (CLI, Vite, build) — it only depends on the
|
|
570
|
+
* core `ctx.rpc` streaming + shared-state surface, not on the hub.
|
|
571
|
+
*/
|
|
572
|
+
async function setupTerminals(ctx, options = {}) {
|
|
573
|
+
const manager = new TerminalManager(ctx, options);
|
|
574
|
+
setTerminalManager(ctx, manager);
|
|
575
|
+
await manager.init();
|
|
576
|
+
for (const fn of serverFunctions) ctx.rpc.register(fn);
|
|
577
|
+
return manager;
|
|
578
|
+
}
|
|
579
|
+
//#endregion
|
|
580
|
+
export { TerminalManager, diagnostics, getTerminalManager, isPtyAvailable, setTerminalManager, setupTerminals };
|