@freestyle-sh/with-pty 0.0.2
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 +79 -0
- package/dist/index.d.ts +140 -0
- package/dist/index.js +439 -0
- package/package.json +28 -0
package/README.md
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# @freestyle-sh/with-pty
|
|
2
|
+
|
|
3
|
+
Generic PTY helper for Freestyle VMs, backed by tmux.
|
|
4
|
+
|
|
5
|
+
By default, PTY helpers also add `/root/.tmux.conf` with:
|
|
6
|
+
|
|
7
|
+
```tmux
|
|
8
|
+
set -g mouse on
|
|
9
|
+
set -g status off
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
Set `applyDefaultTmuxConfig: false` to opt out.
|
|
13
|
+
|
|
14
|
+
## Installation
|
|
15
|
+
|
|
16
|
+
```bash
|
|
17
|
+
pnpm add @freestyle-sh/with-pty freestyle-sandboxes
|
|
18
|
+
```
|
|
19
|
+
|
|
20
|
+
## Usage
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { freestyle, VmSpec } from "freestyle-sandboxes";
|
|
24
|
+
import { VmPty } from "@freestyle-sh/with-pty";
|
|
25
|
+
|
|
26
|
+
const { vm } = await freestyle.vms.create({
|
|
27
|
+
snapshot: new VmSpec({
|
|
28
|
+
with: {
|
|
29
|
+
pty: new VmPty(),
|
|
30
|
+
},
|
|
31
|
+
}),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
const ptyHandle = await vm.pty.createPtySession({
|
|
35
|
+
id: "dev",
|
|
36
|
+
command: "npm run dev",
|
|
37
|
+
cwd: "/root/repo",
|
|
38
|
+
ptySize: { cols: 120, rows: 30 },
|
|
39
|
+
reset: true,
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
await ptyHandle.sendInput("echo hello from pty\n");
|
|
43
|
+
|
|
44
|
+
await ptyHandle.resize({ cols: 160, rows: 40 });
|
|
45
|
+
|
|
46
|
+
const sessions = await vm.pty.listPtySessions();
|
|
47
|
+
console.log(sessions.map((s) => s.id));
|
|
48
|
+
|
|
49
|
+
const output = await ptyHandle.read({
|
|
50
|
+
lines: 80,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
console.log(output);
|
|
54
|
+
|
|
55
|
+
const result = await ptyHandle.wait({ timeoutMs: 5_000 });
|
|
56
|
+
console.log(result.exitCode);
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## API
|
|
60
|
+
|
|
61
|
+
- Process-level methods:
|
|
62
|
+
- `createPtySession({ id, command, cwd, envs, ptySize, reset })`
|
|
63
|
+
- `connectPtySession(sessionId)`
|
|
64
|
+
- `listPtySessions()`
|
|
65
|
+
- `getPtySessionInfo(sessionId)`
|
|
66
|
+
- `resizePtySession(sessionId, ptySize)`
|
|
67
|
+
- `killPtySession(sessionId)`
|
|
68
|
+
- `attachCommand({ sessionId, readOnly })`
|
|
69
|
+
- Handle methods (`PtyHandle`):
|
|
70
|
+
- `sendInput(data)`
|
|
71
|
+
- `read({ lines, includeEscape })`
|
|
72
|
+
- `resize(ptySize)`
|
|
73
|
+
- `wait({ timeoutMs, pollIntervalMs, lines, onData })`
|
|
74
|
+
- `waitForConnection(timeoutMs)`
|
|
75
|
+
- `isConnected()`
|
|
76
|
+
- `disconnect()`
|
|
77
|
+
- `kill()`
|
|
78
|
+
|
|
79
|
+
Note: This implementation uses tmux under the hood. It mirrors Daytona-style semantics where practical, but does not expose a websocket stream; `wait`/`read` use polling via tmux capture.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
import { VmWith, VmWithInstance, VmSpec } from 'freestyle-sandboxes';
|
|
2
|
+
|
|
3
|
+
type VmPtyOptions = {
|
|
4
|
+
installTmux?: boolean;
|
|
5
|
+
defaultWorkdir?: string;
|
|
6
|
+
applyDefaultTmuxConfig?: boolean;
|
|
7
|
+
};
|
|
8
|
+
type PtySize = {
|
|
9
|
+
cols: number;
|
|
10
|
+
rows: number;
|
|
11
|
+
};
|
|
12
|
+
type CreatePtySessionOptions = {
|
|
13
|
+
id: string;
|
|
14
|
+
command?: string;
|
|
15
|
+
cwd?: string;
|
|
16
|
+
envs?: Record<string, string>;
|
|
17
|
+
ptySize?: PtySize;
|
|
18
|
+
reset?: boolean;
|
|
19
|
+
};
|
|
20
|
+
type PtySessionInfo = {
|
|
21
|
+
id: string;
|
|
22
|
+
active: boolean;
|
|
23
|
+
cwd: string;
|
|
24
|
+
cols: number;
|
|
25
|
+
rows: number;
|
|
26
|
+
createdAt: number;
|
|
27
|
+
};
|
|
28
|
+
type PtyReadOptions = {
|
|
29
|
+
lines?: number;
|
|
30
|
+
includeEscape?: boolean;
|
|
31
|
+
};
|
|
32
|
+
type PtyWaitOptions = {
|
|
33
|
+
timeoutMs?: number;
|
|
34
|
+
pollIntervalMs?: number;
|
|
35
|
+
lines?: number;
|
|
36
|
+
onData?: (data: string) => void;
|
|
37
|
+
};
|
|
38
|
+
type PtyWaitResult = {
|
|
39
|
+
exitCode: number | null;
|
|
40
|
+
error?: string;
|
|
41
|
+
output?: string;
|
|
42
|
+
};
|
|
43
|
+
type VmPtySessionOptions = {
|
|
44
|
+
sessionId: string;
|
|
45
|
+
resetSession?: boolean;
|
|
46
|
+
cols?: number;
|
|
47
|
+
rows?: number;
|
|
48
|
+
envs?: Record<string, string>;
|
|
49
|
+
installTmux?: boolean;
|
|
50
|
+
workdir?: string;
|
|
51
|
+
applyDefaultTmuxConfig?: boolean;
|
|
52
|
+
};
|
|
53
|
+
type VmPtySessionLike = {
|
|
54
|
+
attachCommand(readOnly?: boolean): string;
|
|
55
|
+
wrapCommand(command: string, workdir?: string): string;
|
|
56
|
+
wrapServiceCommand(command: string, workdir?: string): string;
|
|
57
|
+
applyToSpec(spec: unknown): unknown;
|
|
58
|
+
captureOutputCommand(options?: PtyReadOptions): string;
|
|
59
|
+
};
|
|
60
|
+
declare class VmPty extends VmWith<VmPtyInstance> {
|
|
61
|
+
options: Required<VmPtyOptions>;
|
|
62
|
+
constructor(options?: VmPtyOptions);
|
|
63
|
+
configureSnapshotSpec(spec: VmSpec): VmSpec;
|
|
64
|
+
createInstance(): VmPtyInstance;
|
|
65
|
+
}
|
|
66
|
+
declare class VmPtySession extends VmWith<VmPtySessionInstance> {
|
|
67
|
+
options: Required<Pick<VmPtySessionOptions, "sessionId" | "resetSession" | "installTmux" | "workdir" | "applyDefaultTmuxConfig">> & Pick<VmPtySessionOptions, "cols" | "rows" | "envs">;
|
|
68
|
+
constructor(options: VmPtySessionOptions);
|
|
69
|
+
private shellEscape;
|
|
70
|
+
private validateEnvKey;
|
|
71
|
+
configureSnapshotSpec(spec: VmSpec): VmSpec;
|
|
72
|
+
applyToSpec(spec: unknown): unknown;
|
|
73
|
+
createInstance(): VmPtySessionInstance;
|
|
74
|
+
attachCommand(readOnly?: boolean): string;
|
|
75
|
+
private buildDetachedTmuxCommand;
|
|
76
|
+
private buildResetCommand;
|
|
77
|
+
wrapCommand(command: string, workdir?: string): string;
|
|
78
|
+
wrapServiceCommand(command: string, workdir?: string): string;
|
|
79
|
+
captureOutputCommand(options?: PtyReadOptions): string;
|
|
80
|
+
}
|
|
81
|
+
declare class VmPtySessionInstance extends VmWithInstance {
|
|
82
|
+
builder: VmPtySession;
|
|
83
|
+
constructor(builder: VmPtySession);
|
|
84
|
+
private shellEscape;
|
|
85
|
+
sendInput(data: string): Promise<ExecResult>;
|
|
86
|
+
readOutput(options?: PtyReadOptions): Promise<string>;
|
|
87
|
+
kill(): Promise<void>;
|
|
88
|
+
}
|
|
89
|
+
type ExecResult = {
|
|
90
|
+
statusCode?: number | undefined | null;
|
|
91
|
+
stdout?: string | undefined | null;
|
|
92
|
+
stderr?: string | undefined | null;
|
|
93
|
+
};
|
|
94
|
+
declare class VmPtyInstance extends VmWithInstance {
|
|
95
|
+
builder: VmPty;
|
|
96
|
+
private readonly stateDir;
|
|
97
|
+
constructor(builder: VmPty);
|
|
98
|
+
private shellEscape;
|
|
99
|
+
private validateSessionId;
|
|
100
|
+
private validateEnvKey;
|
|
101
|
+
private hasPtySession;
|
|
102
|
+
private exitCodePath;
|
|
103
|
+
readExitCode(id: string): Promise<number | null>;
|
|
104
|
+
createPtySession(options: CreatePtySessionOptions): Promise<PtyHandle>;
|
|
105
|
+
connectPtySession(sessionId: string): Promise<PtyHandle>;
|
|
106
|
+
listPtySessions(): Promise<PtySessionInfo[]>;
|
|
107
|
+
getPtySessionInfo(sessionId: string): Promise<PtySessionInfo>;
|
|
108
|
+
killPtySession(sessionId: string): Promise<void>;
|
|
109
|
+
resizePtySession(sessionId: string, ptySize: PtySize): Promise<PtySessionInfo>;
|
|
110
|
+
sendInput(sessionId: string, data: string): Promise<ExecResult>;
|
|
111
|
+
readOutput(sessionId: string, options?: PtyReadOptions): Promise<string>;
|
|
112
|
+
isSessionConnected(sessionId: string): Promise<boolean>;
|
|
113
|
+
attachCommand(options: {
|
|
114
|
+
sessionId: string;
|
|
115
|
+
readOnly?: boolean;
|
|
116
|
+
}): string;
|
|
117
|
+
}
|
|
118
|
+
declare class PtyHandle {
|
|
119
|
+
readonly sessionId: string;
|
|
120
|
+
readonly process: VmPtyInstance;
|
|
121
|
+
exitCode: number | null;
|
|
122
|
+
error?: string;
|
|
123
|
+
private disconnected;
|
|
124
|
+
constructor({ process, sessionId, }: {
|
|
125
|
+
process: VmPtyInstance;
|
|
126
|
+
sessionId: string;
|
|
127
|
+
});
|
|
128
|
+
private ensureConnected;
|
|
129
|
+
sendInput(data: string): Promise<void>;
|
|
130
|
+
read(options?: PtyReadOptions): Promise<string>;
|
|
131
|
+
resize(ptySize: PtySize): Promise<PtySessionInfo>;
|
|
132
|
+
waitForConnection(timeoutMs?: number): Promise<void>;
|
|
133
|
+
wait(options?: PtyWaitOptions): Promise<PtyWaitResult>;
|
|
134
|
+
kill(): Promise<void>;
|
|
135
|
+
disconnect(): void;
|
|
136
|
+
isConnected(): Promise<boolean>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export { PtyHandle, VmPty, VmPtyInstance, VmPtySession, VmPtySessionInstance };
|
|
140
|
+
export type { CreatePtySessionOptions, PtyReadOptions, PtySessionInfo, PtySize, PtyWaitOptions, PtyWaitResult, VmPtyOptions, VmPtySessionLike, VmPtySessionOptions };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,439 @@
|
|
|
1
|
+
import { VmWith, VmSpec, VmWithInstance } from 'freestyle-sandboxes';
|
|
2
|
+
|
|
3
|
+
const DEFAULT_TMUX_CONF = `set -g mouse on
|
|
4
|
+
set -g status off`;
|
|
5
|
+
class VmPty extends VmWith {
|
|
6
|
+
options;
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super();
|
|
9
|
+
this.options = {
|
|
10
|
+
installTmux: options?.installTmux ?? true,
|
|
11
|
+
defaultWorkdir: options?.defaultWorkdir ?? "/root",
|
|
12
|
+
applyDefaultTmuxConfig: options?.applyDefaultTmuxConfig ?? true
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
configureSnapshotSpec(spec) {
|
|
16
|
+
if (!this.options.installTmux && !this.options.applyDefaultTmuxConfig) {
|
|
17
|
+
return spec;
|
|
18
|
+
}
|
|
19
|
+
return this.composeSpecs(
|
|
20
|
+
spec,
|
|
21
|
+
new VmSpec({
|
|
22
|
+
aptDeps: this.options.installTmux ? ["tmux"] : void 0,
|
|
23
|
+
additionalFiles: this.options.applyDefaultTmuxConfig ? {
|
|
24
|
+
"/root/.tmux.conf": {
|
|
25
|
+
content: DEFAULT_TMUX_CONF
|
|
26
|
+
}
|
|
27
|
+
} : void 0
|
|
28
|
+
})
|
|
29
|
+
);
|
|
30
|
+
}
|
|
31
|
+
createInstance() {
|
|
32
|
+
return new VmPtyInstance(this);
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
class VmPtySession extends VmWith {
|
|
36
|
+
options;
|
|
37
|
+
constructor(options) {
|
|
38
|
+
super();
|
|
39
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(options.sessionId)) {
|
|
40
|
+
throw new Error(
|
|
41
|
+
"Invalid PTY session id. Use only letters, numbers, dot, underscore, and hyphen."
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
this.options = {
|
|
45
|
+
sessionId: options.sessionId,
|
|
46
|
+
resetSession: options.resetSession ?? true,
|
|
47
|
+
cols: options.cols,
|
|
48
|
+
rows: options.rows,
|
|
49
|
+
envs: options.envs,
|
|
50
|
+
installTmux: options.installTmux ?? true,
|
|
51
|
+
workdir: options.workdir ?? "/root",
|
|
52
|
+
applyDefaultTmuxConfig: options.applyDefaultTmuxConfig ?? true
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
shellEscape(value) {
|
|
56
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
57
|
+
}
|
|
58
|
+
validateEnvKey(key) {
|
|
59
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
60
|
+
throw new Error(`Invalid env var name: ${key}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
configureSnapshotSpec(spec) {
|
|
64
|
+
return this.applyToSpec(spec);
|
|
65
|
+
}
|
|
66
|
+
applyToSpec(spec) {
|
|
67
|
+
const typedSpec = spec;
|
|
68
|
+
if (!this.options.installTmux && !this.options.applyDefaultTmuxConfig) {
|
|
69
|
+
return typedSpec;
|
|
70
|
+
}
|
|
71
|
+
return this.composeSpecs(
|
|
72
|
+
typedSpec,
|
|
73
|
+
new VmSpec({
|
|
74
|
+
aptDeps: this.options.installTmux ? ["tmux"] : void 0,
|
|
75
|
+
additionalFiles: this.options.applyDefaultTmuxConfig ? {
|
|
76
|
+
"/root/.tmux.conf": {
|
|
77
|
+
content: DEFAULT_TMUX_CONF
|
|
78
|
+
}
|
|
79
|
+
} : void 0
|
|
80
|
+
})
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
createInstance() {
|
|
84
|
+
return new VmPtySessionInstance(this);
|
|
85
|
+
}
|
|
86
|
+
attachCommand(readOnly = false) {
|
|
87
|
+
const flag = readOnly ? "-r " : "";
|
|
88
|
+
return `tmux attach ${flag}-t ${this.options.sessionId}`;
|
|
89
|
+
}
|
|
90
|
+
buildDetachedTmuxCommand(command, workdir) {
|
|
91
|
+
const envPrefix = Object.entries(this.options.envs ?? {}).map(([key, value]) => {
|
|
92
|
+
this.validateEnvKey(key);
|
|
93
|
+
return `${key}=${this.shellEscape(value)}`;
|
|
94
|
+
}).join(" ");
|
|
95
|
+
const runCommand = `${envPrefix ? `${envPrefix} ` : ""}${command}`;
|
|
96
|
+
return [
|
|
97
|
+
"tmux new-session -d",
|
|
98
|
+
`-s ${this.shellEscape(this.options.sessionId)}`,
|
|
99
|
+
`-c ${this.shellEscape(workdir ?? this.options.workdir)}`,
|
|
100
|
+
this.options.cols ? `-x ${this.options.cols}` : "",
|
|
101
|
+
this.options.rows ? `-y ${this.options.rows}` : "",
|
|
102
|
+
this.shellEscape(`bash -lc ${this.shellEscape(runCommand)}`)
|
|
103
|
+
].filter(Boolean).join(" ");
|
|
104
|
+
}
|
|
105
|
+
buildResetCommand() {
|
|
106
|
+
return this.options.resetSession ? `tmux has-session -t ${this.shellEscape(this.options.sessionId)} >/dev/null 2>&1 && tmux kill-session -t ${this.shellEscape(this.options.sessionId)} || true` : "true";
|
|
107
|
+
}
|
|
108
|
+
wrapCommand(command, workdir) {
|
|
109
|
+
const tmuxCommand = this.buildDetachedTmuxCommand(command, workdir);
|
|
110
|
+
const resetCommand = this.buildResetCommand();
|
|
111
|
+
return `bash -lc ${this.shellEscape(`set -e
|
|
112
|
+
${resetCommand}
|
|
113
|
+
${tmuxCommand}`)}`;
|
|
114
|
+
}
|
|
115
|
+
wrapServiceCommand(command, workdir) {
|
|
116
|
+
const tmuxCommand = this.buildDetachedTmuxCommand(command, workdir);
|
|
117
|
+
const resetCommand = this.buildResetCommand();
|
|
118
|
+
return `bash -lc ${this.shellEscape(`set -e
|
|
119
|
+
${resetCommand}
|
|
120
|
+
${tmuxCommand}
|
|
121
|
+
while tmux has-session -t ${this.shellEscape(this.options.sessionId)} >/dev/null 2>&1; do
|
|
122
|
+
sleep 1
|
|
123
|
+
done`)}`;
|
|
124
|
+
}
|
|
125
|
+
captureOutputCommand(options) {
|
|
126
|
+
const lines = options?.lines ?? 200;
|
|
127
|
+
const includeEscape = options?.includeEscape ?? true;
|
|
128
|
+
return [
|
|
129
|
+
"tmux capture-pane",
|
|
130
|
+
includeEscape ? "-e" : "",
|
|
131
|
+
"-p",
|
|
132
|
+
`-t ${this.shellEscape(this.options.sessionId)}`,
|
|
133
|
+
`-S -${lines}`
|
|
134
|
+
].filter(Boolean).join(" ");
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
class VmPtySessionInstance extends VmWithInstance {
|
|
138
|
+
builder;
|
|
139
|
+
constructor(builder) {
|
|
140
|
+
super();
|
|
141
|
+
this.builder = builder;
|
|
142
|
+
}
|
|
143
|
+
shellEscape(value) {
|
|
144
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
145
|
+
}
|
|
146
|
+
async sendInput(data) {
|
|
147
|
+
return this.vm.exec({
|
|
148
|
+
command: `tmux set-buffer -- ${this.shellEscape(data)} && tmux paste-buffer -d -t ${this.shellEscape(this.builder.options.sessionId)}`
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
async readOutput(options) {
|
|
152
|
+
const lines = options?.lines ?? 200;
|
|
153
|
+
const includeEscape = options?.includeEscape ?? true;
|
|
154
|
+
const capture = [
|
|
155
|
+
"tmux capture-pane",
|
|
156
|
+
includeEscape ? "-e" : "",
|
|
157
|
+
"-p",
|
|
158
|
+
`-t ${this.shellEscape(this.builder.options.sessionId)}`,
|
|
159
|
+
`-S -${lines}`
|
|
160
|
+
].filter(Boolean).join(" ");
|
|
161
|
+
const result = await this.vm.exec({ command: capture });
|
|
162
|
+
return result.stdout ?? "";
|
|
163
|
+
}
|
|
164
|
+
async kill() {
|
|
165
|
+
await this.vm.exec({
|
|
166
|
+
command: `tmux has-session -t ${this.shellEscape(this.builder.options.sessionId)} >/dev/null 2>&1 && tmux kill-session -t ${this.shellEscape(this.builder.options.sessionId)} || true`
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
class VmPtyInstance extends VmWithInstance {
|
|
171
|
+
builder;
|
|
172
|
+
stateDir = "/tmp/freestyle-pty";
|
|
173
|
+
constructor(builder) {
|
|
174
|
+
super();
|
|
175
|
+
this.builder = builder;
|
|
176
|
+
}
|
|
177
|
+
shellEscape(value) {
|
|
178
|
+
return `'${value.replace(/'/g, "'\\''")}'`;
|
|
179
|
+
}
|
|
180
|
+
validateSessionId(id) {
|
|
181
|
+
if (!/^[a-zA-Z0-9._-]+$/.test(id)) {
|
|
182
|
+
throw new Error(
|
|
183
|
+
"Invalid PTY session id. Use only letters, numbers, dot, underscore, and hyphen."
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
validateEnvKey(key) {
|
|
188
|
+
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key)) {
|
|
189
|
+
throw new Error(`Invalid env var name: ${key}`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
async hasPtySession(id) {
|
|
193
|
+
const result = await this.vm.exec({
|
|
194
|
+
command: `tmux has-session -t ${this.shellEscape(id)}`
|
|
195
|
+
});
|
|
196
|
+
return result.statusCode === 0;
|
|
197
|
+
}
|
|
198
|
+
exitCodePath(id) {
|
|
199
|
+
return `${this.stateDir}/${id}.exit`;
|
|
200
|
+
}
|
|
201
|
+
async readExitCode(id) {
|
|
202
|
+
const path = this.exitCodePath(id);
|
|
203
|
+
const result = await this.vm.exec({
|
|
204
|
+
command: `if [ -f ${this.shellEscape(path)} ]; then cat ${this.shellEscape(path)}; fi`
|
|
205
|
+
});
|
|
206
|
+
const raw = (result.stdout ?? "").trim();
|
|
207
|
+
if (!raw) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const value = Number.parseInt(raw, 10);
|
|
211
|
+
return Number.isNaN(value) ? null : value;
|
|
212
|
+
}
|
|
213
|
+
async createPtySession(options) {
|
|
214
|
+
this.validateSessionId(options.id);
|
|
215
|
+
const command = options.command ?? "bash -l";
|
|
216
|
+
const cwd = options.cwd ?? this.builder.options.defaultWorkdir;
|
|
217
|
+
const size = options.ptySize;
|
|
218
|
+
const envPrefix = Object.entries(options.envs ?? {}).map(([key, value]) => {
|
|
219
|
+
this.validateEnvKey(key);
|
|
220
|
+
return `export ${key}=${this.shellEscape(value)};`;
|
|
221
|
+
}).join(" ");
|
|
222
|
+
const exitPath = this.exitCodePath(options.id);
|
|
223
|
+
const wrappedCommand = `${envPrefix} ${command}; __pty_status=$?; printf '%s' "$__pty_status" > ${this.shellEscape(exitPath)}; exit "$__pty_status"`;
|
|
224
|
+
const parts = [
|
|
225
|
+
`mkdir -p ${this.shellEscape(this.stateDir)}`,
|
|
226
|
+
`rm -f ${this.shellEscape(exitPath)}`
|
|
227
|
+
];
|
|
228
|
+
if (options.reset) {
|
|
229
|
+
parts.push(
|
|
230
|
+
`tmux has-session -t ${this.shellEscape(options.id)} >/dev/null 2>&1 && tmux kill-session -t ${this.shellEscape(options.id)} || true`
|
|
231
|
+
);
|
|
232
|
+
}
|
|
233
|
+
parts.push(
|
|
234
|
+
[
|
|
235
|
+
"tmux new-session -d",
|
|
236
|
+
`-s ${this.shellEscape(options.id)}`,
|
|
237
|
+
`-c ${this.shellEscape(cwd)}`,
|
|
238
|
+
size ? `-x ${size.cols}` : "",
|
|
239
|
+
size ? `-y ${size.rows}` : "",
|
|
240
|
+
this.shellEscape(`bash -lc ${this.shellEscape(wrappedCommand)}`)
|
|
241
|
+
].filter(Boolean).join(" ")
|
|
242
|
+
);
|
|
243
|
+
const result = await this.vm.exec({ command: parts.join(" && ") });
|
|
244
|
+
if (result.statusCode && result.statusCode !== 0) {
|
|
245
|
+
throw new Error(
|
|
246
|
+
result.stderr ?? `Failed to create PTY session ${options.id}`
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
return new PtyHandle({
|
|
250
|
+
process: this,
|
|
251
|
+
sessionId: options.id
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
async connectPtySession(sessionId) {
|
|
255
|
+
this.validateSessionId(sessionId);
|
|
256
|
+
const exists = await this.hasPtySession(sessionId);
|
|
257
|
+
if (!exists) {
|
|
258
|
+
throw new Error(`PTY session ${sessionId} not found`);
|
|
259
|
+
}
|
|
260
|
+
return new PtyHandle({
|
|
261
|
+
process: this,
|
|
262
|
+
sessionId
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
async listPtySessions() {
|
|
266
|
+
const listResult = await this.vm.exec({
|
|
267
|
+
command: "tmux list-sessions -F '#{session_name}' 2>/dev/null || true"
|
|
268
|
+
});
|
|
269
|
+
const ids = (listResult.stdout ?? "").split(/\r?\n/).map((line) => line.trim()).filter((line) => line.length > 0);
|
|
270
|
+
const infos = [];
|
|
271
|
+
for (const id of ids) {
|
|
272
|
+
infos.push(await this.getPtySessionInfo(id));
|
|
273
|
+
}
|
|
274
|
+
return infos;
|
|
275
|
+
}
|
|
276
|
+
async getPtySessionInfo(sessionId) {
|
|
277
|
+
this.validateSessionId(sessionId);
|
|
278
|
+
const active = await this.hasPtySession(sessionId);
|
|
279
|
+
if (!active) {
|
|
280
|
+
throw new Error(`PTY session ${sessionId} not found`);
|
|
281
|
+
}
|
|
282
|
+
const raw = (await this.vm.exec({
|
|
283
|
+
command: `tmux display-message -p -t ${this.shellEscape(sessionId)} '#{pane_current_path} #{window_width} #{window_height} #{session_created}'`
|
|
284
|
+
})).stdout;
|
|
285
|
+
const [cwd = "", width = "80", height = "24", created = "0"] = (raw ?? "").trim().split(" ");
|
|
286
|
+
return {
|
|
287
|
+
id: sessionId,
|
|
288
|
+
active,
|
|
289
|
+
cwd,
|
|
290
|
+
cols: Number.parseInt(width, 10) || 80,
|
|
291
|
+
rows: Number.parseInt(height, 10) || 24,
|
|
292
|
+
createdAt: Number.parseInt(created, 10) || 0
|
|
293
|
+
};
|
|
294
|
+
}
|
|
295
|
+
async killPtySession(sessionId) {
|
|
296
|
+
this.validateSessionId(sessionId);
|
|
297
|
+
await this.vm.exec({
|
|
298
|
+
command: `tmux has-session -t ${this.shellEscape(sessionId)} >/dev/null 2>&1 && tmux kill-session -t ${this.shellEscape(sessionId)} || true`
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
async resizePtySession(sessionId, ptySize) {
|
|
302
|
+
this.validateSessionId(sessionId);
|
|
303
|
+
await this.vm.exec({
|
|
304
|
+
command: `tmux resize-window -t ${this.shellEscape(sessionId)} -x ${ptySize.cols} -y ${ptySize.rows}`
|
|
305
|
+
});
|
|
306
|
+
return this.getPtySessionInfo(sessionId);
|
|
307
|
+
}
|
|
308
|
+
async sendInput(sessionId, data) {
|
|
309
|
+
this.validateSessionId(sessionId);
|
|
310
|
+
return this.vm.exec({
|
|
311
|
+
command: `tmux set-buffer -- ${this.shellEscape(data)} && tmux paste-buffer -d -t ${this.shellEscape(sessionId)}`
|
|
312
|
+
});
|
|
313
|
+
}
|
|
314
|
+
async readOutput(sessionId, options) {
|
|
315
|
+
this.validateSessionId(sessionId);
|
|
316
|
+
const lines = options?.lines ?? 200;
|
|
317
|
+
const includeEscape = options?.includeEscape ?? true;
|
|
318
|
+
const capture = [
|
|
319
|
+
"tmux capture-pane",
|
|
320
|
+
includeEscape ? "-e" : "",
|
|
321
|
+
"-p",
|
|
322
|
+
`-t ${this.shellEscape(sessionId)}`,
|
|
323
|
+
`-S -${lines}`
|
|
324
|
+
].filter(Boolean).join(" ");
|
|
325
|
+
const result = await this.vm.exec({ command: capture });
|
|
326
|
+
return result.stdout ?? "";
|
|
327
|
+
}
|
|
328
|
+
async isSessionConnected(sessionId) {
|
|
329
|
+
return this.hasPtySession(sessionId);
|
|
330
|
+
}
|
|
331
|
+
attachCommand(options) {
|
|
332
|
+
const flag = options.readOnly ? "-r " : "";
|
|
333
|
+
return `tmux attach ${flag}-t ${options.sessionId}`;
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
class PtyHandle {
|
|
337
|
+
sessionId;
|
|
338
|
+
process;
|
|
339
|
+
exitCode = null;
|
|
340
|
+
error;
|
|
341
|
+
disconnected = false;
|
|
342
|
+
constructor({
|
|
343
|
+
process,
|
|
344
|
+
sessionId
|
|
345
|
+
}) {
|
|
346
|
+
this.process = process;
|
|
347
|
+
this.sessionId = sessionId;
|
|
348
|
+
}
|
|
349
|
+
ensureConnected() {
|
|
350
|
+
if (this.disconnected) {
|
|
351
|
+
throw new Error(`PTY handle for ${this.sessionId} is disconnected`);
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
async sendInput(data) {
|
|
355
|
+
this.ensureConnected();
|
|
356
|
+
const result = await this.process.sendInput(this.sessionId, data);
|
|
357
|
+
if (result.statusCode && result.statusCode !== 0) {
|
|
358
|
+
throw new Error(
|
|
359
|
+
result.stderr ?? `Failed to send input to ${this.sessionId}`
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
async read(options) {
|
|
364
|
+
this.ensureConnected();
|
|
365
|
+
return this.process.readOutput(this.sessionId, options);
|
|
366
|
+
}
|
|
367
|
+
async resize(ptySize) {
|
|
368
|
+
this.ensureConnected();
|
|
369
|
+
return this.process.resizePtySession(this.sessionId, ptySize);
|
|
370
|
+
}
|
|
371
|
+
async waitForConnection(timeoutMs = 1e4) {
|
|
372
|
+
this.ensureConnected();
|
|
373
|
+
const start = Date.now();
|
|
374
|
+
while (Date.now() - start < timeoutMs) {
|
|
375
|
+
if (await this.process.isSessionConnected(this.sessionId)) {
|
|
376
|
+
return;
|
|
377
|
+
}
|
|
378
|
+
await new Promise((resolve) => setTimeout(resolve, 250));
|
|
379
|
+
}
|
|
380
|
+
throw new Error(
|
|
381
|
+
`Timed out waiting for PTY session ${this.sessionId} connection`
|
|
382
|
+
);
|
|
383
|
+
}
|
|
384
|
+
async wait(options) {
|
|
385
|
+
this.ensureConnected();
|
|
386
|
+
const timeoutMs = options?.timeoutMs;
|
|
387
|
+
const pollIntervalMs = options?.pollIntervalMs ?? 500;
|
|
388
|
+
const start = Date.now();
|
|
389
|
+
let previousOutput = "";
|
|
390
|
+
while (true) {
|
|
391
|
+
const output = await this.process.readOutput(this.sessionId, {
|
|
392
|
+
lines: options?.lines ?? 300,
|
|
393
|
+
includeEscape: true
|
|
394
|
+
});
|
|
395
|
+
if (options?.onData) {
|
|
396
|
+
const delta = output.startsWith(previousOutput) ? output.slice(previousOutput.length) : output;
|
|
397
|
+
if (delta.length > 0) {
|
|
398
|
+
options.onData(delta);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
previousOutput = output;
|
|
402
|
+
const connected = await this.process.isSessionConnected(this.sessionId);
|
|
403
|
+
if (!connected) {
|
|
404
|
+
this.disconnected = true;
|
|
405
|
+
const exitCode = await this.process.readExitCode(this.sessionId);
|
|
406
|
+
this.exitCode = exitCode;
|
|
407
|
+
return {
|
|
408
|
+
exitCode,
|
|
409
|
+
output: previousOutput,
|
|
410
|
+
error: this.error
|
|
411
|
+
};
|
|
412
|
+
}
|
|
413
|
+
if (timeoutMs !== void 0 && Date.now() - start >= timeoutMs) {
|
|
414
|
+
this.error = "Timed out waiting for PTY session completion";
|
|
415
|
+
return {
|
|
416
|
+
exitCode: null,
|
|
417
|
+
output: previousOutput,
|
|
418
|
+
error: this.error
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
await new Promise((resolve) => setTimeout(resolve, pollIntervalMs));
|
|
422
|
+
}
|
|
423
|
+
}
|
|
424
|
+
async kill() {
|
|
425
|
+
await this.process.killPtySession(this.sessionId);
|
|
426
|
+
this.disconnected = true;
|
|
427
|
+
}
|
|
428
|
+
disconnect() {
|
|
429
|
+
this.disconnected = true;
|
|
430
|
+
}
|
|
431
|
+
async isConnected() {
|
|
432
|
+
if (this.disconnected) {
|
|
433
|
+
return false;
|
|
434
|
+
}
|
|
435
|
+
return this.process.isSessionConnected(this.sessionId);
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
export { PtyHandle, VmPty, VmPtyInstance, VmPtySession, VmPtySessionInstance };
|
package/package.json
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@freestyle-sh/with-pty",
|
|
3
|
+
"version": "0.0.2",
|
|
4
|
+
"private": false,
|
|
5
|
+
"dependencies": {
|
|
6
|
+
"freestyle-sandboxes": "^0.1.28"
|
|
7
|
+
},
|
|
8
|
+
"type": "module",
|
|
9
|
+
"main": "./dist/index.js",
|
|
10
|
+
"types": "./dist/index.d.ts",
|
|
11
|
+
"exports": {
|
|
12
|
+
".": {
|
|
13
|
+
"types": "./dist/index.d.ts",
|
|
14
|
+
"import": "./dist/index.js"
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
"source": "./src/index.ts",
|
|
18
|
+
"files": [
|
|
19
|
+
"dist"
|
|
20
|
+
],
|
|
21
|
+
"devDependencies": {
|
|
22
|
+
"pkgroll": "^2.11.2",
|
|
23
|
+
"typescript": "^5.8.3"
|
|
24
|
+
},
|
|
25
|
+
"scripts": {
|
|
26
|
+
"build": "pkgroll"
|
|
27
|
+
}
|
|
28
|
+
}
|