Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,12 +152,13 @@ PR titles should follow the conventional commit format as they are used for rele
src/
├── cli.ts # Main CLI entry point
├── commands/ # Command implementations
│ ├── devbox/ # Devbox commands
│ ├── devbox/ # Devbox commands (ssh, pty, exec, etc.)
│ ├── snapshot/ # Snapshot commands
│ ├── blueprint/ # Blueprint commands
│ └── object/ # Object storage commands
├── components/ # React/Ink UI components
├── hooks/ # Custom React hooks
├── lib/ # Reusable protocol clients (PTY, etc.)
├── mcp/ # MCP server implementation
├── router/ # Navigation router
├── screens/ # Full-screen views
Expand Down
5 changes: 3 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ rli devbox delete <devbox-id>
- 🎯 **CLI mode** — Traditional commands with text, JSON, and YAML output for scripting
- ⚡ Fast and responsive with pagination
- 📦 Manage devboxes, snapshots, and blueprints
- 🚀 Execute commands, SSH, view logs in devboxes
- 🚀 Execute commands, SSH, PTY shell, view logs in devboxes
- 🤖 **Model Context Protocol (MCP) server for AI integration**

## Installation
Expand Down Expand Up @@ -69,7 +69,7 @@ The URL must be of the form `https://api.<domain>`. The CLI derives other servic
| API | `https://api.<domain>` (the value of `RUNLOOP_BASE_URL`) |
| Platform | `https://platform.<domain>` |
| SSH | `ssh.<domain>:443` |
| Tunnels | `tunnel.<domain>` |
| Tunnels | `tunnel.<domain>` (PTY sessions reach the devbox over a tunnel created via the API) |

## Usage

Expand Down Expand Up @@ -109,6 +109,7 @@ rli devbox suspend <id> # Suspend a devbox
rli devbox resume <id> # Resume a suspended devbox
rli devbox shutdown <id> # Shutdown a devbox
rli devbox ssh <id> # SSH into a devbox
rli devbox pty <id> # Connect to a devbox PTY session via W...
rli devbox scp <src> <dst> # Copy files to/from a devbox using scp...
rli devbox rsync <src> <dst> # Sync files to/from a devbox using rsy...
rli devbox tunnel <id> <ports> # Create a port-forwarding tunnel to a ...
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@
"ink-spinner": "5.0.0",
"ink-text-input": "6.0.0",
"react": "19.2.0",
"ws": "^8.18.0",
"tar-stream": "3.1.7",
"yaml": "2.8.3",
"zustand": "5.0.10"
Expand Down Expand Up @@ -142,6 +143,7 @@
"prettier": "3.8.1",
"ts-jest": "29.4.6",
"ts-node": "10.9.2",
"@types/ws": "^8.5.0",
"typescript": "5.9.3"
}
}
13 changes: 13 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

217 changes: 217 additions & 0 deletions src/commands/devbox/pty.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,217 @@
import WebSocket from "ws";
import { cliStatus } from "../../utils/cliStatus.js";
import { outputError } from "../../utils/output.js";
import { waitForReady } from "../../utils/ssh.js";
import {
getPtyBaseUrl,
createPtySessionReleaser,
resolvePtyWebSocketUrl,
createPtyTunnel,
getPtyTunnelBaseUrl,
isLocalPtyOverride,
settleAfterPtyTunnel,
refreshPtySessionAfterAttach,
startPtyIoSession,
} from "../../lib/pty-client.js";
import { openPtyWebSocket } from "../../lib/pty-ws.js";

/** SIGINT/SIGTERM → optional notify then close socket; returns disposer for ws close/error cleanup. */
function registerPtyInterruptHandlers(
ws: WebSocket,
beforeClose: () => void,
): () => void {
function dispose() {
process.removeListener("SIGINT", onSigint);
process.removeListener("SIGTERM", onSigterm);
}
function onSigint() {
beforeClose();
dispose();
ws.close();
}
function onSigterm() {
beforeClose();
dispose();
ws.close();
}
process.on("SIGINT", onSigint);
process.on("SIGTERM", onSigterm);
return dispose;
}

function writePtyStreamToStdout(data: WebSocket.RawData): void {
if (Buffer.isBuffer(data)) {
process.stdout.write(data);
} else if (data instanceof ArrayBuffer) {
process.stdout.write(Buffer.from(data));
} else if (Array.isArray(data)) {
process.stdout.write(Buffer.concat(data));
} else {
process.stdout.write(String(data));
}
}

interface PtyOptions {
session?: string;
command?: string;
wait?: boolean;
timeout?: string;
pollInterval?: string;
output?: string;
}

export async function ptyDevbox(devboxId: string, options: PtyOptions = {}) {
try {
if (options.wait !== false) {
cliStatus(`Waiting for devbox ${devboxId} to be ready...`);
const isReady = await waitForReady(
devboxId,
parseInt(options.timeout || "180"),
parseInt(options.pollInterval || "3"),
);
if (!isReady) {
outputError(`Devbox ${devboxId} is not ready. Please try again later.`);
}
}

let baseUrl: string;
let authToken: string | undefined;

if (isLocalPtyOverride()) {
baseUrl = getPtyBaseUrl();
} else {
cliStatus(`Creating PTY tunnel for ${devboxId}...`);
const tunnel = await createPtyTunnel(devboxId);
await settleAfterPtyTunnel();
baseUrl = getPtyTunnelBaseUrl(tunnel.tunnel_key);
authToken = tunnel.auth_token;
}

const sessionName = options.session?.trim() || devboxId;

if (options.command) {
await execCommand(baseUrl, sessionName, options.command, authToken);
} else {
await interactiveSession(baseUrl, sessionName, authToken);
}
} catch (error) {
outputError("Failed to start PTY session", error);
}
}

const PTY_EXEC_TIMEOUT_MS = (() => {
const v = parseInt(process.env.RUNLOOP_PTY_EXEC_TIMEOUT_MS || "0", 10);
return isNaN(v) || v < 0 ? 0 : v;
})();

async function execCommand(
baseUrl: string,
sessionName: string,
command: string,
authToken?: string,
): Promise<void> {
const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, {
cols: 80,
rows: 24,
authToken,
});
const ws = await openPtyWebSocket(wsUrl, authToken);

const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
const disposeInterruptSignals = registerPtyInterruptHandlers(ws, releaseOnce);

let timeoutId: ReturnType<typeof setTimeout> | undefined;

const completion = new Promise<void>((resolve, reject) => {
const finish = () => {
if (timeoutId !== undefined) clearTimeout(timeoutId);
releaseOnce();
disposeInterruptSignals();
};

// Attach listeners synchronously *before* any await — otherwise messages
// emitted between ws open and listener registration are dropped.
ws.on("message", (data: WebSocket.RawData) => {
writePtyStreamToStdout(data);
});

ws.on("close", () => {
finish();
resolve();
});

ws.on("error", (err: Error) => {
finish();
reject(err);
});

if (PTY_EXEC_TIMEOUT_MS > 0) {
timeoutId = setTimeout(() => {
releaseOnce();
ws.close();
}, PTY_EXEC_TIMEOUT_MS);
}
});

await refreshPtySessionAfterAttach(
ws,
baseUrl,
sessionName,
80,
24,
authToken,
);
// Send the command followed by `exit` so the shell terminates and the
// server closes the WebSocket. Without this, the session would stay open
// after the command finished and the CLI would hang.
ws.send(command + "\nexit\n");

return completion;
}

async function interactiveSession(
baseUrl: string,
sessionName: string,
authToken?: string,
): Promise<void> {
const cols = process.stdout.columns || 80;
const rows = process.stdout.rows || 24;

const wsUrl = await resolvePtyWebSocketUrl(baseUrl, sessionName, {
cols,
rows,
authToken,
});
const ws = await openPtyWebSocket(wsUrl, authToken);

// Attach IO listeners before the refresh round-trip so server output
// emitted during the ptyControl HTTP call is not dropped.
const releaseOnce = createPtySessionReleaser(baseUrl, sessionName, authToken);
const { dispose, done } = startPtyIoSession(
ws,
baseUrl,
sessionName,
authToken,
);
const disposeSignals = registerPtyInterruptHandlers(ws, releaseOnce);

await refreshPtySessionAfterAttach(
ws,
baseUrl,
sessionName,
cols,
rows,
authToken,
);

try {
await done;
releaseOnce();
} catch (err) {
releaseOnce();
throw err;
} finally {
dispose();
disposeSignals();
}
}
8 changes: 7 additions & 1 deletion src/commands/object/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,7 +214,13 @@ async function readStdinBuffer(): Promise<Buffer> {
export async function uploadObject(options: UploadObjectOptions) {
try {
const client = getClient();
const { paths, name, contentType, output: outputFormat } = options;
const {
paths: rawPaths,
name,
contentType,
output: outputFormat,
} = options;
const paths = [...rawPaths];

if (paths.length === 0) {
if (!processUtils.stdin.isTTY) {
Expand Down
Loading
Loading