@termfleet/terminal 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +31 -0
- package/dist/attach.d.ts +29 -0
- package/dist/attach.js +141 -0
- package/dist/client.d.ts +53 -0
- package/dist/client.js +100 -0
- package/dist/internal/exec.d.ts +15 -0
- package/dist/internal/exec.js +192 -0
- package/dist/internal/process-tree.d.ts +1 -0
- package/dist/internal/process-tree.js +41 -0
- package/dist/tmux-stream.d.ts +31 -0
- package/dist/tmux-stream.js +187 -0
- package/dist/tmux.d.ts +128 -0
- package/dist/tmux.js +782 -0
- package/package.json +68 -0
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import pty from "@homebridge/node-pty-prebuilt-multiarch";
|
|
2
|
+
import fs from "node:fs";
|
|
3
|
+
import { assertSession, getWindowSize } from "./tmux.js";
|
|
4
|
+
export class TmuxTerminalAttachError extends Error {
|
|
5
|
+
phase;
|
|
6
|
+
constructor(phase, cause) {
|
|
7
|
+
super(errorMessage(cause), { cause });
|
|
8
|
+
this.name = "TmuxTerminalAttachError";
|
|
9
|
+
this.phase = phase;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
// Attach one ephemeral PTY client to a durable tmux session. The returned
|
|
13
|
+
// attachment owns every listener and descriptor it creates; dispose is
|
|
14
|
+
// idempotent and never terminates the tmux session itself.
|
|
15
|
+
export function attachTmuxTerminalSocket(options) {
|
|
16
|
+
const window = options.window ?? 0;
|
|
17
|
+
if (!Number.isInteger(window) || window < 0) {
|
|
18
|
+
throw new Error("window must be a non-negative integer");
|
|
19
|
+
}
|
|
20
|
+
let tmuxSize;
|
|
21
|
+
try {
|
|
22
|
+
assertSession(options.terminalId, options.tmuxSocket);
|
|
23
|
+
tmuxSize = getWindowSize({ name: options.terminalId, socket: options.tmuxSocket, window });
|
|
24
|
+
}
|
|
25
|
+
catch (error) {
|
|
26
|
+
throw new TmuxTerminalAttachError("resolve", error);
|
|
27
|
+
}
|
|
28
|
+
const cols = options.cols ?? tmuxSize.width;
|
|
29
|
+
const rows = options.rows ?? tmuxSize.height;
|
|
30
|
+
let reconciledPty;
|
|
31
|
+
try {
|
|
32
|
+
reconciledPty = spawnReconciledPty("tmux", [
|
|
33
|
+
...(options.tmuxSocket ? ["-L", options.tmuxSocket] : []),
|
|
34
|
+
"-T",
|
|
35
|
+
"RGB",
|
|
36
|
+
"attach-session",
|
|
37
|
+
"-t",
|
|
38
|
+
options.terminalId
|
|
39
|
+
], {
|
|
40
|
+
cols,
|
|
41
|
+
cwd: options.cwd ?? process.cwd(),
|
|
42
|
+
env: {
|
|
43
|
+
...process.env,
|
|
44
|
+
TERM: "screen-256color"
|
|
45
|
+
},
|
|
46
|
+
rows
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
catch (error) {
|
|
50
|
+
throw new TmuxTerminalAttachError("spawn", error);
|
|
51
|
+
}
|
|
52
|
+
const { child, orphanPtmxFds } = reconciledPty;
|
|
53
|
+
let disposed = false;
|
|
54
|
+
let outputSubscription;
|
|
55
|
+
let exitSubscription;
|
|
56
|
+
const onClose = () => dispose();
|
|
57
|
+
const onMessage = (raw) => {
|
|
58
|
+
try {
|
|
59
|
+
const message = JSON.parse(raw.toString());
|
|
60
|
+
if (message.type !== "input") {
|
|
61
|
+
throw new Error(`Unsupported websocket message type: ${message.type}`);
|
|
62
|
+
}
|
|
63
|
+
if (typeof message.data !== "string") {
|
|
64
|
+
throw new Error("Input message did not include string data.");
|
|
65
|
+
}
|
|
66
|
+
void Promise.resolve(options.authorizeInput?.())
|
|
67
|
+
.then(async (authorization) => {
|
|
68
|
+
if (disposed)
|
|
69
|
+
return;
|
|
70
|
+
await options.onInput?.(message.data, authorization);
|
|
71
|
+
if (!disposed)
|
|
72
|
+
child.write(message.data);
|
|
73
|
+
})
|
|
74
|
+
.catch((error) => {
|
|
75
|
+
sendTerminalSocketError(options.socket, error, options.errorLabel);
|
|
76
|
+
options.socket.close(1008, "terminal access ended");
|
|
77
|
+
});
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
sendTerminalSocketError(options.socket, error, options.errorLabel);
|
|
81
|
+
}
|
|
82
|
+
};
|
|
83
|
+
const dispose = () => {
|
|
84
|
+
if (disposed)
|
|
85
|
+
return;
|
|
86
|
+
disposed = true;
|
|
87
|
+
options.socket.off("close", onClose);
|
|
88
|
+
options.socket.off("message", onMessage);
|
|
89
|
+
outputSubscription?.dispose();
|
|
90
|
+
exitSubscription?.dispose();
|
|
91
|
+
releasePtyMaster(child, orphanPtmxFds);
|
|
92
|
+
};
|
|
93
|
+
options.socket.send(JSON.stringify({ cols, rows, type: "hello" }));
|
|
94
|
+
outputSubscription = child.onData((data) => {
|
|
95
|
+
if (!disposed && options.socket.readyState === options.socket.OPEN) {
|
|
96
|
+
options.socket.send(JSON.stringify({ data: wellFormedTerminalText(data), type: "output" }));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
exitSubscription = child.onExit(({ exitCode, signal }) => {
|
|
100
|
+
if (!disposed && options.socket.readyState === options.socket.OPEN) {
|
|
101
|
+
options.socket.send(JSON.stringify({ exitCode, signal, type: "exit" }));
|
|
102
|
+
options.socket.close(1000, "tmux client exited");
|
|
103
|
+
}
|
|
104
|
+
dispose();
|
|
105
|
+
});
|
|
106
|
+
options.socket.on("close", onClose);
|
|
107
|
+
options.socket.on("message", onMessage);
|
|
108
|
+
return { dispose };
|
|
109
|
+
}
|
|
110
|
+
export function spawnReconciledPty(file, args, options) {
|
|
111
|
+
if (process.platform !== "darwin") {
|
|
112
|
+
return { child: pty.spawn(file, args, options), orphanPtmxFds: [] };
|
|
113
|
+
}
|
|
114
|
+
const before = listCharacterDeviceFds();
|
|
115
|
+
const child = pty.spawn(file, args, options);
|
|
116
|
+
const masterFd = child._fd;
|
|
117
|
+
const masterMajor = characterDeviceMajor(masterFd);
|
|
118
|
+
const orphanPtmxFds = [];
|
|
119
|
+
if (typeof masterFd === "number" && masterMajor !== null) {
|
|
120
|
+
for (const fd of listCharacterDeviceFds()) {
|
|
121
|
+
if (!before.has(fd) && fd !== masterFd && characterDeviceMajor(fd) === masterMajor) {
|
|
122
|
+
orphanPtmxFds.push(fd);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
return { child, orphanPtmxFds };
|
|
127
|
+
}
|
|
128
|
+
export function releasePtyMaster(child, orphanPtmxFds = []) {
|
|
129
|
+
try {
|
|
130
|
+
child.destroy();
|
|
131
|
+
}
|
|
132
|
+
catch {
|
|
133
|
+
// Child already gone; destroy is best-effort.
|
|
134
|
+
}
|
|
135
|
+
for (const fd of orphanPtmxFds) {
|
|
136
|
+
if (characterDeviceMajor(fd) === null)
|
|
137
|
+
continue;
|
|
138
|
+
try {
|
|
139
|
+
fs.closeSync(fd);
|
|
140
|
+
}
|
|
141
|
+
catch {
|
|
142
|
+
// Already closed.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function characterDeviceMajor(fd) {
|
|
147
|
+
if (typeof fd !== "number")
|
|
148
|
+
return null;
|
|
149
|
+
try {
|
|
150
|
+
const stat = fs.fstatSync(fd);
|
|
151
|
+
return stat.isCharacterDevice() ? (stat.rdev >> 24) & 0xff : null;
|
|
152
|
+
}
|
|
153
|
+
catch {
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
function listCharacterDeviceFds() {
|
|
158
|
+
const fds = new Set();
|
|
159
|
+
try {
|
|
160
|
+
for (const entry of fs.readdirSync("/dev/fd")) {
|
|
161
|
+
const fd = Number(entry);
|
|
162
|
+
if (Number.isInteger(fd) && characterDeviceMajor(fd) !== null)
|
|
163
|
+
fds.add(fd);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
catch {
|
|
167
|
+
// No /dev/fd (for example Windows conpty).
|
|
168
|
+
}
|
|
169
|
+
return fds;
|
|
170
|
+
}
|
|
171
|
+
function sendTerminalSocketError(socket, error, label = "terminal") {
|
|
172
|
+
if (socket.readyState !== socket.OPEN)
|
|
173
|
+
return;
|
|
174
|
+
socket.send(JSON.stringify({
|
|
175
|
+
message: `\r\n[${label}] ${errorMessage(error)}\r\n`,
|
|
176
|
+
type: "error"
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
function errorMessage(error) {
|
|
180
|
+
return error instanceof Error ? error.message : String(error);
|
|
181
|
+
}
|
|
182
|
+
function wellFormedTerminalText(value) {
|
|
183
|
+
const maybeWellFormed = value;
|
|
184
|
+
if (typeof maybeWellFormed.toWellFormed === "function")
|
|
185
|
+
return maybeWellFormed.toWellFormed();
|
|
186
|
+
return value.replace(/[\uD800-\uDBFF](?![\uDC00-\uDFFF])|(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]/g, "\uFFFD");
|
|
187
|
+
}
|
package/dist/tmux.d.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
export type TmuxPane = {
|
|
2
|
+
pane: number;
|
|
3
|
+
id: string;
|
|
4
|
+
rootPid?: number;
|
|
5
|
+
dead: boolean;
|
|
6
|
+
currentCommand: string;
|
|
7
|
+
};
|
|
8
|
+
export type TmuxWindowSize = {
|
|
9
|
+
width: number;
|
|
10
|
+
height: number;
|
|
11
|
+
};
|
|
12
|
+
export type CreateSessionOptions = {
|
|
13
|
+
cwd?: string;
|
|
14
|
+
env?: Record<string, string>;
|
|
15
|
+
name: string;
|
|
16
|
+
owner?: string;
|
|
17
|
+
ownerOption?: string;
|
|
18
|
+
panes: number;
|
|
19
|
+
socket?: string;
|
|
20
|
+
};
|
|
21
|
+
export declare function assertTmux(): void;
|
|
22
|
+
export declare function targetForSession(sessionName: string): string;
|
|
23
|
+
export declare function assertSession(name: string, socket?: string): void;
|
|
24
|
+
export declare function createSession({ cwd, env, name, owner, ownerOption: ownershipOption, panes, socket }: CreateSessionOptions): Promise<{
|
|
25
|
+
name: string;
|
|
26
|
+
panes: number;
|
|
27
|
+
}>;
|
|
28
|
+
export declare function sessionOwner(session: string, socket?: string, ownershipOption?: string): string | undefined;
|
|
29
|
+
export declare function sessionsOwnedBy(owner: string, socket?: string, ownershipOption?: string): string[];
|
|
30
|
+
export declare function killSessionProcessTree({ session, socket }: {
|
|
31
|
+
session: string;
|
|
32
|
+
socket?: string;
|
|
33
|
+
}): void;
|
|
34
|
+
export declare function listTmuxSessionNames(socket?: string): string[];
|
|
35
|
+
export declare function selectOrphanSessions({ allSessions, ownedSessions, prefix }: {
|
|
36
|
+
allSessions: string[];
|
|
37
|
+
ownedSessions: Iterable<string>;
|
|
38
|
+
prefix: string;
|
|
39
|
+
}): string[];
|
|
40
|
+
export declare function classifyPrefixSessions({ allSessions, ownedSessions, prefix }: {
|
|
41
|
+
allSessions: string[];
|
|
42
|
+
ownedSessions: Iterable<string>;
|
|
43
|
+
prefix: string;
|
|
44
|
+
}): {
|
|
45
|
+
discovered: string[];
|
|
46
|
+
kept: string[];
|
|
47
|
+
orphans: string[];
|
|
48
|
+
tracked: string[];
|
|
49
|
+
};
|
|
50
|
+
export declare function waitForPanesReady({ expectedPanes, socket, target, timeoutMs }: {
|
|
51
|
+
target: string;
|
|
52
|
+
expectedPanes: number;
|
|
53
|
+
timeoutMs?: number;
|
|
54
|
+
socket?: string;
|
|
55
|
+
}): Promise<TmuxPane[]>;
|
|
56
|
+
export declare function inspectPanes(target: string, socket?: string): TmuxPane[];
|
|
57
|
+
export declare function inspectPanesAsync(target: string, socket?: string): Promise<TmuxPane[]>;
|
|
58
|
+
declare function parseListPanesOutput(output: string): {
|
|
59
|
+
active: boolean;
|
|
60
|
+
cwd: string;
|
|
61
|
+
id: string;
|
|
62
|
+
pane: number;
|
|
63
|
+
rootPid: number;
|
|
64
|
+
session: string;
|
|
65
|
+
title: string;
|
|
66
|
+
window: number;
|
|
67
|
+
}[];
|
|
68
|
+
export declare function listPanes(socket?: string): {
|
|
69
|
+
active: boolean;
|
|
70
|
+
cwd: string;
|
|
71
|
+
id: string;
|
|
72
|
+
pane: number;
|
|
73
|
+
rootPid: number;
|
|
74
|
+
session: string;
|
|
75
|
+
title: string;
|
|
76
|
+
window: number;
|
|
77
|
+
}[];
|
|
78
|
+
export declare function listPanesAsync(socket?: string): Promise<ReturnType<typeof parseListPanesOutput>>;
|
|
79
|
+
export declare function clientForTty(tty: string, socket?: string): {
|
|
80
|
+
height: number;
|
|
81
|
+
target: string;
|
|
82
|
+
width: number;
|
|
83
|
+
} | undefined;
|
|
84
|
+
export declare function clientForTtyAsync(tty: string, socket?: string): Promise<{
|
|
85
|
+
height: number;
|
|
86
|
+
target: string;
|
|
87
|
+
width: number;
|
|
88
|
+
} | undefined>;
|
|
89
|
+
export declare function clientTtysForSession(session: string, socket?: string): string[];
|
|
90
|
+
export declare function getWindowSize({ name, socket, window }: {
|
|
91
|
+
name: string;
|
|
92
|
+
window?: number;
|
|
93
|
+
socket?: string;
|
|
94
|
+
}): TmuxWindowSize;
|
|
95
|
+
export declare function listWindowSizesAsync(socket?: string): Promise<Map<string, TmuxWindowSize>>;
|
|
96
|
+
export declare function setPaneStyle(target: string, style: string, socket?: string): Promise<void>;
|
|
97
|
+
export declare function clearPaneStyle(target: string, socket?: string): Promise<void>;
|
|
98
|
+
export declare function capturePane({ lines, preserveEscapes, socket, target }: {
|
|
99
|
+
target: string;
|
|
100
|
+
lines?: number;
|
|
101
|
+
preserveEscapes?: boolean;
|
|
102
|
+
socket?: string;
|
|
103
|
+
}): string;
|
|
104
|
+
export declare function capturePaneAsync({ lines, preserveEscapes, socket, target }: {
|
|
105
|
+
target: string;
|
|
106
|
+
lines?: number;
|
|
107
|
+
preserveEscapes?: boolean;
|
|
108
|
+
socket?: string;
|
|
109
|
+
}): Promise<string>;
|
|
110
|
+
export type TerminalInputDelivery = "keystream" | "submitted-line" | "submitted-paste";
|
|
111
|
+
export declare function sendInterruptAsync({ socket, target }: {
|
|
112
|
+
target: string;
|
|
113
|
+
socket?: string;
|
|
114
|
+
}): Promise<void>;
|
|
115
|
+
export declare function sendInputAsync({ data, deadlineMs, socket, submitMode, target }: {
|
|
116
|
+
target: string;
|
|
117
|
+
data: string;
|
|
118
|
+
deadlineMs?: number;
|
|
119
|
+
submitMode?: "retry" | "single";
|
|
120
|
+
socket?: string;
|
|
121
|
+
}): Promise<TerminalInputDelivery>;
|
|
122
|
+
export declare function attachSession({ name, socket, window }: {
|
|
123
|
+
name: string;
|
|
124
|
+
socket?: string;
|
|
125
|
+
window?: number;
|
|
126
|
+
}): void;
|
|
127
|
+
export declare function submittedTextFromInput(data: string): string | undefined;
|
|
128
|
+
export {};
|