@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.
@@ -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 };