@pocketenv/cli 0.2.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 +341 -0
- package/bun.lock +601 -0
- package/dist/index.js +1181 -0
- package/package.json +57 -0
- package/src/client.ts +6 -0
- package/src/cmd/create.ts +46 -0
- package/src/cmd/env.ts +126 -0
- package/src/cmd/list.ts +80 -0
- package/src/cmd/login.ts +49 -0
- package/src/cmd/logout.ts +20 -0
- package/src/cmd/rm.ts +24 -0
- package/src/cmd/secret.ts +127 -0
- package/src/cmd/ssh/cloudflare.ts +212 -0
- package/src/cmd/ssh/index.ts +96 -0
- package/src/cmd/ssh/terminal.ts +0 -0
- package/src/cmd/ssh/tty.ts +221 -0
- package/src/cmd/sshkeys.ts +176 -0
- package/src/cmd/start.ts +25 -0
- package/src/cmd/stop.ts +22 -0
- package/src/cmd/tailscale.ts +106 -0
- package/src/cmd/whoami.ts +24 -0
- package/src/index.ts +187 -0
- package/src/lib/env.ts +11 -0
- package/src/lib/getAccessToken.ts +36 -0
- package/src/lib/sodium.ts +15 -0
- package/src/lib/sshKeys.ts +122 -0
- package/src/types/file.ts +5 -0
- package/src/types/profile.ts +9 -0
- package/src/types/providers.ts +1 -0
- package/src/types/sandbox.ts +23 -0
- package/src/types/secret.ts +5 -0
- package/src/types/sshkeys.ts +6 -0
- package/src/types/tailscale-auth-key.ts +5 -0
- package/src/types/variable.ts +6 -0
- package/src/types/volume.ts +6 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import chalk from "chalk";
|
|
3
|
+
import consola from "consola";
|
|
4
|
+
import getAccessToken from "../../lib/getAccessToken";
|
|
5
|
+
import { client } from "../../client";
|
|
6
|
+
import { env } from "../../lib/env";
|
|
7
|
+
import type { Sandbox } from "../../types/sandbox";
|
|
8
|
+
|
|
9
|
+
// ── Protocol (mirrors @cloudflare/sandbox xterm addon) ───────────────────────
|
|
10
|
+
//
|
|
11
|
+
// Server → Client:
|
|
12
|
+
// - Binary frame raw PTY output (UTF-8 bytes), write directly to stdout
|
|
13
|
+
// - Text frame JSON control message:
|
|
14
|
+
// { type: "ready" } session is live
|
|
15
|
+
// { type: "error", message: string } terminal error
|
|
16
|
+
// { type: "exit", code: number } remote shell exited
|
|
17
|
+
//
|
|
18
|
+
// Client → Server:
|
|
19
|
+
// - Binary frame raw keystroke bytes (UTF-8) — same as TextEncoder output
|
|
20
|
+
// - Text frame { type: "resize", cols: number, rows: number }
|
|
21
|
+
|
|
22
|
+
type ControlMessage =
|
|
23
|
+
| { type: "ready" }
|
|
24
|
+
| { type: "error"; message: string }
|
|
25
|
+
| { type: "exit"; code: number; signal?: string };
|
|
26
|
+
|
|
27
|
+
function sendInput(ws: WebSocket, data: Buffer): void {
|
|
28
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
29
|
+
ws.send(data);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function sendResize(ws: WebSocket, cols: number, rows: number): void {
|
|
34
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
35
|
+
ws.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Resolve the Cloudflare worker URL for a given baseSandbox (template) name.
|
|
41
|
+
*
|
|
42
|
+
* The web app constructs the URL with:
|
|
43
|
+
* CF_URL.replace("sbx", worker).replace("claude-code", "claudecode")
|
|
44
|
+
*
|
|
45
|
+
* The production CF_URL is "https://sbx.pocketenv.io", so for a worker named
|
|
46
|
+
* "claude-code" the final URL becomes "https://claudecode.pocketenv.io".
|
|
47
|
+
*/
|
|
48
|
+
function resolveWorkerUrl(baseSandbox: string, cfUrl: string): string {
|
|
49
|
+
return cfUrl.replace("sbx", baseSandbox).replace("claude-code", "claudecode");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function ssh(sandbox: Sandbox) {
|
|
53
|
+
const token = await getAccessToken();
|
|
54
|
+
const authToken = env.POCKETENV_TOKEN || token;
|
|
55
|
+
|
|
56
|
+
const tokenResponse = await client.get<{ token?: string }>(
|
|
57
|
+
"/xrpc/io.pocketenv.actor.getTerminalToken",
|
|
58
|
+
{ headers: { Authorization: `Bearer ${authToken}` } },
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
const terminalToken = tokenResponse.data.token;
|
|
62
|
+
if (!terminalToken) {
|
|
63
|
+
consola.error("Failed to obtain a terminal token.");
|
|
64
|
+
process.exit(1);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const cfBaseUrl = env.POCKETENV_CF_URL;
|
|
68
|
+
const workerUrl = resolveWorkerUrl(sandbox.baseSandbox, cfBaseUrl);
|
|
69
|
+
|
|
70
|
+
// Convert http(s) → ws(s)
|
|
71
|
+
const wsBase = workerUrl.replace(/^http/, "ws");
|
|
72
|
+
const wsUrl = new URL(`${wsBase}/v1/sandboxes/${sandbox.id}/ws/terminal`);
|
|
73
|
+
wsUrl.searchParams.set("t", terminalToken);
|
|
74
|
+
wsUrl.searchParams.set("session", crypto.randomUUID());
|
|
75
|
+
|
|
76
|
+
let cols = process.stdout.columns ?? 220;
|
|
77
|
+
let rows = process.stdout.rows ?? 50;
|
|
78
|
+
|
|
79
|
+
consola.info(
|
|
80
|
+
`Connecting to ${chalk.cyanBright(sandbox.name)} via Cloudflare Sandbox…`,
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
// Use default binaryType ("nodebuffer") so binary frames arrive as Buffer,
|
|
84
|
+
// which is what isBinary:true + Buffer.isBuffer() correctly identifies.
|
|
85
|
+
const ws = new WebSocket(wsUrl.toString(), {
|
|
86
|
+
headers: { "User-Agent": "pocketenv-cli" },
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
let exiting = false;
|
|
90
|
+
let stdinAttached = false;
|
|
91
|
+
|
|
92
|
+
function teardown(code = 0): void {
|
|
93
|
+
if (exiting) return;
|
|
94
|
+
exiting = true;
|
|
95
|
+
|
|
96
|
+
if (process.stdin.isTTY) {
|
|
97
|
+
try {
|
|
98
|
+
process.stdin.setRawMode(false);
|
|
99
|
+
} catch {
|
|
100
|
+
// ignore – may already be restored
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
process.stdin.pause();
|
|
104
|
+
|
|
105
|
+
if (
|
|
106
|
+
ws.readyState === WebSocket.OPEN ||
|
|
107
|
+
ws.readyState === WebSocket.CONNECTING
|
|
108
|
+
) {
|
|
109
|
+
ws.close(1000, "client disconnect");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
process.exit(code);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
ws.on("open", () => {
|
|
116
|
+
// Nothing to do on open — wait for "ready" before sending resize or input.
|
|
117
|
+
// (Matches the xterm addon behaviour: onSocketOpen only registers listeners,
|
|
118
|
+
// sendResize is called from handleControlMessage("ready").)
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
ws.on("message", (raw: WebSocket.RawData, isBinary: boolean) => {
|
|
122
|
+
if (isBinary) {
|
|
123
|
+
// Raw PTY output — write the bytes directly to stdout unchanged.
|
|
124
|
+
// raw is a Buffer (default nodebuffer binaryType).
|
|
125
|
+
process.stdout.write(raw as Buffer);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Text frame → JSON control message.
|
|
130
|
+
let msg: ControlMessage;
|
|
131
|
+
try {
|
|
132
|
+
msg = JSON.parse(raw.toString()) as ControlMessage;
|
|
133
|
+
} catch {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
switch (msg.type) {
|
|
138
|
+
case "ready": {
|
|
139
|
+
// ── Session is live ──────────────────────────────────────────────
|
|
140
|
+
// 1. Send current terminal dimensions now that the PTY is ready.
|
|
141
|
+
sendResize(ws, cols, rows);
|
|
142
|
+
|
|
143
|
+
if (stdinAttached) break;
|
|
144
|
+
stdinAttached = true;
|
|
145
|
+
|
|
146
|
+
// 2. Switch stdin to raw mode — every keystroke is forwarded
|
|
147
|
+
// immediately, no local echo or line-buffering.
|
|
148
|
+
if (process.stdin.isTTY) {
|
|
149
|
+
process.stdin.setRawMode(true);
|
|
150
|
+
}
|
|
151
|
+
// Keep stdin flowing as a raw binary stream. Using no encoding
|
|
152
|
+
// means data events fire with Buffer objects, which we send
|
|
153
|
+
// directly as binary WebSocket frames — no encoding round-trip.
|
|
154
|
+
process.stdin.resume();
|
|
155
|
+
|
|
156
|
+
// stdin → WebSocket (binary frame, UTF-8 bytes)
|
|
157
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
158
|
+
sendInput(ws, chunk);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Terminal resize → notify the remote PTY.
|
|
162
|
+
process.stdout.on("resize", () => {
|
|
163
|
+
cols = process.stdout.columns ?? cols;
|
|
164
|
+
rows = process.stdout.rows ?? rows;
|
|
165
|
+
sendResize(ws, cols, rows);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
break;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case "error":
|
|
172
|
+
process.stderr.write(
|
|
173
|
+
`\r\n${chalk.red("Terminal error:")} ${msg.message}\r\n`,
|
|
174
|
+
);
|
|
175
|
+
teardown(1);
|
|
176
|
+
break;
|
|
177
|
+
|
|
178
|
+
case "exit":
|
|
179
|
+
process.stderr.write(
|
|
180
|
+
`\r\n${chalk.dim(
|
|
181
|
+
`Session exited with code ${msg.code}${msg.signal ? ` (${msg.signal})` : ""}`,
|
|
182
|
+
)}\r\n`,
|
|
183
|
+
);
|
|
184
|
+
teardown(msg.code ?? 0);
|
|
185
|
+
break;
|
|
186
|
+
}
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
ws.on("close", (code, reason) => {
|
|
190
|
+
if (!exiting) {
|
|
191
|
+
process.stderr.write(
|
|
192
|
+
`\r\n${chalk.yellow("Connection closed")} (${code}${reason.length ? ` – ${reason}` : ""})\r\n`,
|
|
193
|
+
);
|
|
194
|
+
teardown(0);
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
ws.on("error", (err: Error) => {
|
|
199
|
+
consola.error("WebSocket error:", err.message);
|
|
200
|
+
teardown(1);
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
process.on("SIGINT", () => teardown(0));
|
|
204
|
+
process.on("SIGTERM", () => teardown(0));
|
|
205
|
+
|
|
206
|
+
await new Promise<void>((resolve) => {
|
|
207
|
+
ws.on("close", resolve);
|
|
208
|
+
ws.on("error", () => resolve());
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
export default ssh;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import consola from "consola";
|
|
3
|
+
import getAccessToken from "../../lib/getAccessToken";
|
|
4
|
+
import { client } from "../../client";
|
|
5
|
+
import { env } from "../../lib/env";
|
|
6
|
+
import type { Sandbox } from "../../types/sandbox";
|
|
7
|
+
import type { Profile } from "../../types/profile";
|
|
8
|
+
import cloudflare from "./cloudflare";
|
|
9
|
+
import tty from "./tty";
|
|
10
|
+
|
|
11
|
+
async function ssh(sandboxName: string | undefined) {
|
|
12
|
+
const token = await getAccessToken();
|
|
13
|
+
const authToken = env.POCKETENV_TOKEN || token;
|
|
14
|
+
|
|
15
|
+
let sandbox: Sandbox;
|
|
16
|
+
|
|
17
|
+
if (!sandboxName) {
|
|
18
|
+
// No name provided – list the user's sandboxes and pick the first running one.
|
|
19
|
+
const profile = await client.get<Profile>(
|
|
20
|
+
"/xrpc/io.pocketenv.actor.getProfile",
|
|
21
|
+
{ headers: { Authorization: `Bearer ${authToken}` } },
|
|
22
|
+
);
|
|
23
|
+
|
|
24
|
+
const response = await client.get<{ sandboxes: Sandbox[] }>(
|
|
25
|
+
"/xrpc/io.pocketenv.actor.getActorSandboxes",
|
|
26
|
+
{
|
|
27
|
+
params: { did: profile.data.did, offset: 0, limit: 100 },
|
|
28
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
29
|
+
},
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const runningSandboxes = response.data.sandboxes.filter(
|
|
33
|
+
(s) => s.status === "RUNNING",
|
|
34
|
+
);
|
|
35
|
+
|
|
36
|
+
if (runningSandboxes.length === 0) {
|
|
37
|
+
consola.error(
|
|
38
|
+
`No running sandboxes found. ` +
|
|
39
|
+
`Start one with ${chalk.greenBright("pocketenv start <sandbox>")} first.`,
|
|
40
|
+
);
|
|
41
|
+
process.exit(1);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
sandbox = runningSandboxes[0] as Sandbox;
|
|
45
|
+
consola.info(`Connecting to sandbox ${chalk.greenBright(sandbox.name)}…`);
|
|
46
|
+
} else {
|
|
47
|
+
// Look up the named sandbox.
|
|
48
|
+
const response = await client.get<{ sandbox: Sandbox | null }>(
|
|
49
|
+
"/xrpc/io.pocketenv.sandbox.getSandbox",
|
|
50
|
+
{
|
|
51
|
+
params: { id: sandboxName },
|
|
52
|
+
headers: { Authorization: `Bearer ${authToken}` },
|
|
53
|
+
},
|
|
54
|
+
);
|
|
55
|
+
|
|
56
|
+
if (!response.data.sandbox) {
|
|
57
|
+
consola.error(`Sandbox ${chalk.yellowBright(sandboxName)} not found.`);
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
sandbox = response.data.sandbox;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (sandbox.status !== "RUNNING") {
|
|
65
|
+
consola.error(
|
|
66
|
+
`Sandbox ${chalk.yellowBright(sandbox.name)} is not running. ` +
|
|
67
|
+
`Start it with ${chalk.greenBright(`pocketenv start ${sandbox.name}`)}.`,
|
|
68
|
+
);
|
|
69
|
+
process.exit(1);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// export type Provider = "daytona" | "deno" | "cloudflare" | "vercel" | "sprites";
|
|
73
|
+
switch (sandbox.provider) {
|
|
74
|
+
case "cloudflare":
|
|
75
|
+
await cloudflare(sandbox);
|
|
76
|
+
break;
|
|
77
|
+
case "daytona":
|
|
78
|
+
break;
|
|
79
|
+
case "deno":
|
|
80
|
+
break;
|
|
81
|
+
case "vercel":
|
|
82
|
+
break;
|
|
83
|
+
case "sprites":
|
|
84
|
+
await tty(sandbox);
|
|
85
|
+
break;
|
|
86
|
+
default:
|
|
87
|
+
consola.error(
|
|
88
|
+
`Sandbox ${chalk.yellowBright(sandbox.name)} uses provider ` +
|
|
89
|
+
`${chalk.cyan(sandbox.provider)}, but this command only supports ` +
|
|
90
|
+
`${chalk.cyan("cloudflare")}, ${chalk.cyan("daytona")}, ${chalk.cyan("deno")}, ${chalk.cyan("vercel")}, or ${chalk.cyan("sprites")} sandboxes.`,
|
|
91
|
+
);
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export default ssh;
|
|
File without changes
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import chalk from "chalk";
|
|
2
|
+
import consola from "consola";
|
|
3
|
+
import getAccessToken from "../../lib/getAccessToken";
|
|
4
|
+
import { env } from "../../lib/env";
|
|
5
|
+
import type { Sandbox } from "../../types/sandbox";
|
|
6
|
+
import { EventSource } from "eventsource";
|
|
7
|
+
import type { ErrorEvent } from "eventsource";
|
|
8
|
+
import axios from "axios";
|
|
9
|
+
|
|
10
|
+
// ── Protocol (mirrors TtyTerminal web component) ──────────────────────────────
|
|
11
|
+
//
|
|
12
|
+
// Server → Client (SSE stream at GET /tty/:id/stream):
|
|
13
|
+
// event: output data: { "data": "<string>" } raw PTY output chunk
|
|
14
|
+
// event: exit data: { "code": <number> } remote shell exited
|
|
15
|
+
//
|
|
16
|
+
// Client → Server:
|
|
17
|
+
// POST /tty/:id/input Content-Type: text/plain raw keystroke bytes
|
|
18
|
+
// POST /tty/:id/resize Content-Type: application/json { cols, rows }
|
|
19
|
+
|
|
20
|
+
async function sendInput(
|
|
21
|
+
ttyUrl: string,
|
|
22
|
+
sandboxId: string,
|
|
23
|
+
data: string | Buffer,
|
|
24
|
+
token: string,
|
|
25
|
+
): Promise<void> {
|
|
26
|
+
try {
|
|
27
|
+
await axios.post(
|
|
28
|
+
`${ttyUrl}/tty/${sandboxId}/input`,
|
|
29
|
+
data instanceof Buffer ? data.toString("utf-8") : data,
|
|
30
|
+
{
|
|
31
|
+
headers: {
|
|
32
|
+
"Content-Type": "text/plain",
|
|
33
|
+
Authorization: `Bearer ${token}`,
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
);
|
|
37
|
+
} catch {
|
|
38
|
+
// session may have closed — swallow the error silently
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function sendResize(
|
|
43
|
+
ttyUrl: string,
|
|
44
|
+
sandboxId: string,
|
|
45
|
+
cols: number,
|
|
46
|
+
rows: number,
|
|
47
|
+
token: string,
|
|
48
|
+
): Promise<void> {
|
|
49
|
+
try {
|
|
50
|
+
await axios.post(
|
|
51
|
+
`${ttyUrl}/tty/${sandboxId}/resize`,
|
|
52
|
+
{ cols, rows },
|
|
53
|
+
{
|
|
54
|
+
headers: {
|
|
55
|
+
"Content-Type": "application/json",
|
|
56
|
+
Authorization: `Bearer ${token}`,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
);
|
|
60
|
+
} catch {
|
|
61
|
+
// ignore transient resize errors
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Build a custom fetch function that injects the Authorization header into
|
|
67
|
+
* every SSE request. The eventsource v3 package uses a fetch-based
|
|
68
|
+
* implementation and exposes this hook via `EventSourceInit.fetch`.
|
|
69
|
+
*/
|
|
70
|
+
function makeAuthFetch(
|
|
71
|
+
token: string,
|
|
72
|
+
): (url: string | URL, init: RequestInit) => Promise<Response> {
|
|
73
|
+
return (url: string | URL, init: RequestInit): Promise<Response> => {
|
|
74
|
+
const headers = new Headers((init.headers as Record<string, string>) ?? {});
|
|
75
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
76
|
+
return fetch(url, { ...init, headers });
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function ssh(sandbox: Sandbox): Promise<void> {
|
|
81
|
+
const token = await getAccessToken();
|
|
82
|
+
const authToken = env.POCKETENV_TOKEN || token;
|
|
83
|
+
|
|
84
|
+
const ttyUrl = env.POCKETENV_TTY_URL;
|
|
85
|
+
|
|
86
|
+
let cols = process.stdout.columns ?? 220;
|
|
87
|
+
let rows = process.stdout.rows ?? 50;
|
|
88
|
+
|
|
89
|
+
consola.info(
|
|
90
|
+
`Connecting to ${chalk.cyanBright(sandbox.name)} via TTY stream…`,
|
|
91
|
+
);
|
|
92
|
+
|
|
93
|
+
let exiting = false;
|
|
94
|
+
let es: EventSource | null = null;
|
|
95
|
+
let stdinAttached = false;
|
|
96
|
+
|
|
97
|
+
function teardown(code = 0): void {
|
|
98
|
+
if (exiting) return;
|
|
99
|
+
exiting = true;
|
|
100
|
+
|
|
101
|
+
if (process.stdin.isTTY) {
|
|
102
|
+
try {
|
|
103
|
+
process.stdin.setRawMode(false);
|
|
104
|
+
} catch {
|
|
105
|
+
// ignore – may already be restored by the time teardown runs
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
process.stdin.pause();
|
|
109
|
+
|
|
110
|
+
if (es) {
|
|
111
|
+
es.close();
|
|
112
|
+
es = null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
process.exit(code);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function attachStdin(): void {
|
|
119
|
+
if (stdinAttached) return;
|
|
120
|
+
stdinAttached = true;
|
|
121
|
+
|
|
122
|
+
// Switch stdin to raw mode — every keystroke is forwarded immediately,
|
|
123
|
+
// with no local echo or line-buffering.
|
|
124
|
+
if (process.stdin.isTTY) {
|
|
125
|
+
process.stdin.setRawMode(true);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Keep stdin flowing as a raw binary stream.
|
|
129
|
+
process.stdin.resume();
|
|
130
|
+
|
|
131
|
+
// stdin → POST /tty/:id/input
|
|
132
|
+
process.stdin.on("data", (chunk: Buffer) => {
|
|
133
|
+
sendInput(ttyUrl, sandbox.id, chunk, authToken);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
// Terminal resize → notify the remote PTY.
|
|
137
|
+
process.stdout.on("resize", () => {
|
|
138
|
+
cols = process.stdout.columns ?? cols;
|
|
139
|
+
rows = process.stdout.rows ?? rows;
|
|
140
|
+
sendResize(ttyUrl, sandbox.id, cols, rows, authToken);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// Mirror TtyTerminal: print a magenta "Connecting…" hint before the stream
|
|
145
|
+
// opens, then erase it once the `open` event fires.
|
|
146
|
+
process.stdout.write(`\x1b[35mConnecting to terminal...\x1b[0m\r\n`);
|
|
147
|
+
|
|
148
|
+
// Open the SSE stream.
|
|
149
|
+
// eventsource v3 is fetch-based, so we inject the Authorization header via
|
|
150
|
+
// a custom fetch implementation instead of an `headers` init option.
|
|
151
|
+
es = new EventSource(`${ttyUrl}/tty/${sandbox.id}/stream`, {
|
|
152
|
+
fetch: makeAuthFetch(authToken),
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
es.addEventListener("open", () => {
|
|
156
|
+
// Erase the "Connecting…" line (carriage-return + erase-to-end-of-line),
|
|
157
|
+
// exactly as TtyTerminal does with `instance.write("\r\x1b[K")`.
|
|
158
|
+
process.stdout.write("\r\x1b[K");
|
|
159
|
+
|
|
160
|
+
// Sync terminal dimensions immediately after connecting, then attach stdin.
|
|
161
|
+
sendResize(ttyUrl, sandbox.id, cols, rows, authToken).then(() => {
|
|
162
|
+
attachStdin();
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
// `event: output` data: { "data": "..." }
|
|
167
|
+
es.addEventListener("output", (event: MessageEvent) => {
|
|
168
|
+
try {
|
|
169
|
+
const { data } = JSON.parse(event.data as string) as { data: string };
|
|
170
|
+
process.stdout.write(data);
|
|
171
|
+
} catch {
|
|
172
|
+
// Fall back to writing the raw SSE data if the JSON wrapper is absent.
|
|
173
|
+
process.stdout.write(event.data as string);
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
// `event: exit` data: { "code": <number> }
|
|
178
|
+
es.addEventListener("exit", (event: MessageEvent) => {
|
|
179
|
+
let code = 0;
|
|
180
|
+
try {
|
|
181
|
+
const parsed = JSON.parse(event.data as string) as { code: number };
|
|
182
|
+
code = parsed.code ?? 0;
|
|
183
|
+
process.stderr.write(
|
|
184
|
+
`\r\n${chalk.dim(`Process exited with code ${code}`)}\r\n`,
|
|
185
|
+
);
|
|
186
|
+
} catch {
|
|
187
|
+
process.stderr.write(`\r\n${chalk.dim("Process exited.")}\r\n`);
|
|
188
|
+
}
|
|
189
|
+
teardown(code);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
// `onerror` receives an `ErrorEvent` (eventsource v3 type).
|
|
193
|
+
// readyState === 2 (CLOSED) means the stream is gone and the client will
|
|
194
|
+
// NOT auto-retry. readyState === 0 (CONNECTING) is an auto-retry — let it.
|
|
195
|
+
es.onerror = (err: ErrorEvent) => {
|
|
196
|
+
// The eventsource package exposes readyState on the EventSource instance.
|
|
197
|
+
if (es && es.readyState === EventSource.CLOSED) {
|
|
198
|
+
const detail = err.message ? ` (${err.message})` : "";
|
|
199
|
+
process.stderr.write(
|
|
200
|
+
`\r\n${chalk.red(`Terminal connection lost${detail}`)}\r\n`,
|
|
201
|
+
);
|
|
202
|
+
teardown(1);
|
|
203
|
+
}
|
|
204
|
+
};
|
|
205
|
+
|
|
206
|
+
process.on("SIGINT", () => teardown(0));
|
|
207
|
+
process.on("SIGTERM", () => teardown(0));
|
|
208
|
+
|
|
209
|
+
// Block until teardown() fires (which calls process.exit, but the Promise
|
|
210
|
+
// is here as a safety net for future refactors that remove process.exit).
|
|
211
|
+
await new Promise<void>((resolve) => {
|
|
212
|
+
const poll = setInterval(() => {
|
|
213
|
+
if (exiting) {
|
|
214
|
+
clearInterval(poll);
|
|
215
|
+
resolve();
|
|
216
|
+
}
|
|
217
|
+
}, 200);
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export default ssh;
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import { editor, input } from "@inquirer/prompts";
|
|
2
|
+
import getAccessToken from "../lib/getAccessToken";
|
|
3
|
+
import { generateEd25519SSHKeyPair } from "../lib/sshKeys";
|
|
4
|
+
import consola from "consola";
|
|
5
|
+
import fs from "node:fs/promises";
|
|
6
|
+
import encrypt from "../lib/sodium";
|
|
7
|
+
import { client } from "../client";
|
|
8
|
+
import type { Sandbox } from "../types/sandbox";
|
|
9
|
+
import { env } from "../lib/env";
|
|
10
|
+
import type { SshKeys } from "../types/sshkeys";
|
|
11
|
+
import chalk from "chalk";
|
|
12
|
+
|
|
13
|
+
export async function getSshKey(sandbox: string) {
|
|
14
|
+
const token = await getAccessToken();
|
|
15
|
+
|
|
16
|
+
const { data } = await client.get<{ sandbox: Sandbox }>(
|
|
17
|
+
"/xrpc/io.pocketenv.sandbox.getSandbox",
|
|
18
|
+
{
|
|
19
|
+
params: {
|
|
20
|
+
id: sandbox,
|
|
21
|
+
},
|
|
22
|
+
headers: {
|
|
23
|
+
Authorization: `Bearer ${token}`,
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
);
|
|
27
|
+
|
|
28
|
+
if (!data.sandbox) {
|
|
29
|
+
consola.error(`Sandbox not found: ${chalk.greenBright(sandbox)}`);
|
|
30
|
+
process.exit(1);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
try {
|
|
34
|
+
const { data: sshKeys } = await client.get<SshKeys>(
|
|
35
|
+
"/xrpc/io.pocketenv.sandbox.getSshKeys",
|
|
36
|
+
{
|
|
37
|
+
params: {
|
|
38
|
+
id: data.sandbox.id,
|
|
39
|
+
},
|
|
40
|
+
headers: {
|
|
41
|
+
Authorization: `Bearer ${env.POCKETENV_TOKEN || token}`,
|
|
42
|
+
},
|
|
43
|
+
},
|
|
44
|
+
);
|
|
45
|
+
|
|
46
|
+
consola.log("\nPrivate Key:");
|
|
47
|
+
consola.log(sshKeys.privateKey.replace(/\\n/g, "\n"));
|
|
48
|
+
consola.log("\nPublic Key:");
|
|
49
|
+
consola.log(sshKeys.publicKey, "\n");
|
|
50
|
+
} catch (error) {
|
|
51
|
+
consola.info(
|
|
52
|
+
`No SSH keys found for this sandbox.\n Create one with ${chalk.greenBright(`pocketenv sshkeys put ${sandbox} --generate`)}.`,
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export async function putKeys(
|
|
58
|
+
sandbox: string,
|
|
59
|
+
options: { generate?: boolean; publicKey?: string; privateKey?: string },
|
|
60
|
+
) {
|
|
61
|
+
const token = await getAccessToken();
|
|
62
|
+
let privateKey: string | undefined;
|
|
63
|
+
let publicKey: string | undefined;
|
|
64
|
+
|
|
65
|
+
if (options.generate) {
|
|
66
|
+
const generated = await generateEd25519SSHKeyPair("");
|
|
67
|
+
privateKey = generated.privateKey;
|
|
68
|
+
publicKey = generated.publicKey;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (options.privateKey && !options.generate) {
|
|
72
|
+
privateKey = await fs.readFile(options.privateKey, "utf8");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (options.publicKey && !options.generate) {
|
|
76
|
+
publicKey = await fs.readFile(options.publicKey, "utf8");
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const validatePrivateKey = (value: string): string | true => {
|
|
80
|
+
const trimmed = value.trim();
|
|
81
|
+
if (!trimmed.startsWith("-----BEGIN")) {
|
|
82
|
+
return "Private key must start with a PEM header (e.g. -----BEGIN OPENSSH PRIVATE KEY-----)";
|
|
83
|
+
}
|
|
84
|
+
if (!trimmed.endsWith("-----")) {
|
|
85
|
+
return "Private key must end with a PEM footer (e.g. -----END OPENSSH PRIVATE KEY-----)";
|
|
86
|
+
}
|
|
87
|
+
return true;
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
if (!privateKey) {
|
|
91
|
+
privateKey = (
|
|
92
|
+
await editor({
|
|
93
|
+
message: "Enter your SSH private key (opens in $EDITOR):",
|
|
94
|
+
postfix: ".pem",
|
|
95
|
+
waitForUserInput: false,
|
|
96
|
+
validate: validatePrivateKey,
|
|
97
|
+
})
|
|
98
|
+
).trim();
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!publicKey) {
|
|
102
|
+
publicKey = (
|
|
103
|
+
await input({
|
|
104
|
+
message: "Enter your SSH public key:",
|
|
105
|
+
validate: (value: string): string | true =>
|
|
106
|
+
value.trim().length > 0 ? true : "Public key cannot be empty.",
|
|
107
|
+
})
|
|
108
|
+
).trim();
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const { data } = await client.get<{ sandbox: Sandbox }>(
|
|
112
|
+
"/xrpc/io.pocketenv.sandbox.getSandbox",
|
|
113
|
+
{
|
|
114
|
+
params: {
|
|
115
|
+
id: sandbox,
|
|
116
|
+
},
|
|
117
|
+
headers: {
|
|
118
|
+
Authorization: `Bearer ${token}`,
|
|
119
|
+
},
|
|
120
|
+
},
|
|
121
|
+
);
|
|
122
|
+
|
|
123
|
+
if (!data.sandbox) {
|
|
124
|
+
consola.error(`Sandbox not found: ${chalk.greenBright(sandbox)}`);
|
|
125
|
+
process.exit(1);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const encryptedPrivateKey = await encrypt(privateKey);
|
|
129
|
+
|
|
130
|
+
const redacted = (() => {
|
|
131
|
+
const header = "-----BEGIN OPENSSH PRIVATE KEY-----";
|
|
132
|
+
const footer = "-----END OPENSSH PRIVATE KEY-----";
|
|
133
|
+
const headerIndex = privateKey.indexOf(header);
|
|
134
|
+
const footerIndex = privateKey.indexOf(footer);
|
|
135
|
+
if (headerIndex === -1 || footerIndex === -1)
|
|
136
|
+
return privateKey.replace(/\n/g, "\\n");
|
|
137
|
+
const body = privateKey.slice(headerIndex + header.length, footerIndex);
|
|
138
|
+
const chars = body.split("");
|
|
139
|
+
const nonNewlineIndices = chars
|
|
140
|
+
.map((c, i) => (c !== "\n" ? i : -1))
|
|
141
|
+
.filter((i) => i !== -1);
|
|
142
|
+
const maskedBody =
|
|
143
|
+
nonNewlineIndices.length > 15
|
|
144
|
+
? (() => {
|
|
145
|
+
const middleIndices = nonNewlineIndices.slice(10, -5);
|
|
146
|
+
middleIndices.forEach((i) => {
|
|
147
|
+
chars[i] = "*";
|
|
148
|
+
});
|
|
149
|
+
return chars.join("");
|
|
150
|
+
})()
|
|
151
|
+
: body;
|
|
152
|
+
return `${header}${maskedBody}${footer}`.replace(/\n/g, "\\n");
|
|
153
|
+
})();
|
|
154
|
+
|
|
155
|
+
await client.post(
|
|
156
|
+
"/xrpc/io.pocketenv.sandbox.putSshKeys",
|
|
157
|
+
{
|
|
158
|
+
id: data.sandbox.id,
|
|
159
|
+
privateKey: encryptedPrivateKey,
|
|
160
|
+
publicKey,
|
|
161
|
+
redacted,
|
|
162
|
+
},
|
|
163
|
+
{
|
|
164
|
+
headers: {
|
|
165
|
+
Authorization: `Bearer ${env.POCKETENV_TOKEN || token}`,
|
|
166
|
+
},
|
|
167
|
+
},
|
|
168
|
+
);
|
|
169
|
+
|
|
170
|
+
consola.log("\nPrivate Key:");
|
|
171
|
+
consola.log(redacted.replace(/\\n/g, "\n"));
|
|
172
|
+
consola.log("\nPublic Key:");
|
|
173
|
+
consola.log(publicKey, "\n");
|
|
174
|
+
|
|
175
|
+
consola.success("SSH keys saved successfully!");
|
|
176
|
+
}
|