@rigkit/provider-freestyle 0.1.8
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 +10 -0
- package/package.json +36 -0
- package/src/auth.ts +24 -0
- package/src/index.ts +166 -0
- package/src/provider.test.ts +135 -0
- package/src/provider.ts +500 -0
- package/src/store.test.ts +38 -0
- package/src/store.ts +142 -0
- package/src/terminal-session.test.ts +182 -0
- package/src/terminal-session.ts +821 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,821 @@
|
|
|
1
|
+
import type { ServerWebSocket, Subprocess } from "bun";
|
|
2
|
+
import type { ProviderInteractionSession } from "@rigkit/engine";
|
|
3
|
+
|
|
4
|
+
export type FreestyleTerminalSessionRequest = {
|
|
5
|
+
title: string;
|
|
6
|
+
command: string;
|
|
7
|
+
remoteCommand?: string;
|
|
8
|
+
instructions?: string;
|
|
9
|
+
nodePath?: string;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type FreestyleTerminalSessionResult = {
|
|
13
|
+
finished: true;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
type ClientMessage =
|
|
17
|
+
| { type: "input"; data: string }
|
|
18
|
+
| { type: "finish" }
|
|
19
|
+
| { type: "resize"; cols: number; rows: number };
|
|
20
|
+
|
|
21
|
+
type ServerMessage =
|
|
22
|
+
| { type: "output"; data: string }
|
|
23
|
+
| { type: "status"; status: string; exitCode?: number; canFinish?: boolean };
|
|
24
|
+
|
|
25
|
+
type SocketData = {
|
|
26
|
+
token: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export function createFreestyleTerminalSession(
|
|
30
|
+
request: FreestyleTerminalSessionRequest,
|
|
31
|
+
): ProviderInteractionSession<FreestyleTerminalSessionResult> {
|
|
32
|
+
const id = crypto.randomUUID();
|
|
33
|
+
const token = crypto.randomUUID();
|
|
34
|
+
let stopped = false;
|
|
35
|
+
let processExitCode: number | undefined;
|
|
36
|
+
let settled = false;
|
|
37
|
+
let remoteCommandStarted = false;
|
|
38
|
+
let terminalCols = 100;
|
|
39
|
+
let terminalRows = 28;
|
|
40
|
+
let terminalQueryBuffer = "";
|
|
41
|
+
let proc: Subprocess<"pipe", "pipe", "pipe"> | undefined;
|
|
42
|
+
let stdin: { write(data: Uint8Array): unknown; flush?(): unknown } | undefined;
|
|
43
|
+
let complete!: (result: FreestyleTerminalSessionResult) => void;
|
|
44
|
+
let fail!: (error: Error) => void;
|
|
45
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
46
|
+
const outputBuffer: string[] = [];
|
|
47
|
+
const startupInput = request.remoteCommand ? ensureTrailingNewline(request.remoteCommand) : undefined;
|
|
48
|
+
|
|
49
|
+
const completed = new Promise<FreestyleTerminalSessionResult>((resolve, reject) => {
|
|
50
|
+
complete = resolve;
|
|
51
|
+
fail = reject;
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const server = Bun.serve<SocketData>({
|
|
55
|
+
hostname: "127.0.0.1",
|
|
56
|
+
port: 0,
|
|
57
|
+
fetch(httpRequest, server) {
|
|
58
|
+
const url = new URL(httpRequest.url);
|
|
59
|
+
|
|
60
|
+
if (url.pathname === "/favicon.ico") {
|
|
61
|
+
return new Response(null, { status: 204 });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
if (url.searchParams.get("token") !== token) {
|
|
65
|
+
return new Response("Not found", { status: 404 });
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (url.pathname === "/terminal" && server.upgrade(httpRequest, { data: { token } })) {
|
|
69
|
+
return;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (url.pathname === "/" && httpRequest.method === "GET") {
|
|
73
|
+
return htmlResponse(renderInteractionPage(request, { startupInput }));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (url.pathname === "/complete" && httpRequest.method === "POST") {
|
|
77
|
+
requestFinish();
|
|
78
|
+
return htmlResponse(renderInteractionPage(request, { completed: true }));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
return new Response("Not found", { status: 404 });
|
|
82
|
+
},
|
|
83
|
+
websocket: {
|
|
84
|
+
open(ws) {
|
|
85
|
+
sockets.add(ws);
|
|
86
|
+
for (const chunk of outputBuffer) send(ws, { type: "output", data: chunk });
|
|
87
|
+
sendStatus(ws);
|
|
88
|
+
startProcess();
|
|
89
|
+
},
|
|
90
|
+
message(_ws, raw) {
|
|
91
|
+
const message = parseClientMessage(raw);
|
|
92
|
+
if (!message) return;
|
|
93
|
+
if (message.type === "input") {
|
|
94
|
+
writeInput(message.data);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (message.type === "resize") {
|
|
98
|
+
terminalCols = message.cols;
|
|
99
|
+
terminalRows = message.rows;
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
requestFinish();
|
|
103
|
+
},
|
|
104
|
+
close(ws) {
|
|
105
|
+
sockets.delete(ws);
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
return {
|
|
111
|
+
id,
|
|
112
|
+
title: request.title,
|
|
113
|
+
url: `http://127.0.0.1:${server.port}/?token=${encodeURIComponent(token)}`,
|
|
114
|
+
instructions: request.instructions,
|
|
115
|
+
completed,
|
|
116
|
+
stop: () => {
|
|
117
|
+
if (stopped) return;
|
|
118
|
+
stopped = true;
|
|
119
|
+
proc?.kill();
|
|
120
|
+
server.stop(true);
|
|
121
|
+
},
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
function startProcess(): void {
|
|
125
|
+
if (proc || processExitCode !== undefined) return;
|
|
126
|
+
|
|
127
|
+
broadcast({
|
|
128
|
+
type: "status",
|
|
129
|
+
status: "Connected",
|
|
130
|
+
canFinish: false,
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
proc = Bun.spawn(["sh", "-lc", `exec ${request.command}`], {
|
|
134
|
+
stdin: "pipe",
|
|
135
|
+
stdout: "pipe",
|
|
136
|
+
stderr: "pipe",
|
|
137
|
+
});
|
|
138
|
+
stdin = proc.stdin;
|
|
139
|
+
|
|
140
|
+
pipeOutput(proc.stdout);
|
|
141
|
+
pipeOutput(proc.stderr);
|
|
142
|
+
|
|
143
|
+
proc.exited.then((code) => {
|
|
144
|
+
processExitCode = code;
|
|
145
|
+
stdin = undefined;
|
|
146
|
+
appendOutput(`\r\n[shell exited ${code}]\r\n`);
|
|
147
|
+
if (settled || stopped) return;
|
|
148
|
+
if (code === 0) {
|
|
149
|
+
broadcast({ type: "status", status: "Shell exited", exitCode: code, canFinish: true });
|
|
150
|
+
} else {
|
|
151
|
+
const error = new Error(`Interactive command "${request.title}" exited ${code}`);
|
|
152
|
+
broadcast({ type: "status", status: error.message, exitCode: code, canFinish: false });
|
|
153
|
+
fail(error);
|
|
154
|
+
}
|
|
155
|
+
}).catch((error) => {
|
|
156
|
+
if (settled || stopped) return;
|
|
157
|
+
fail(error instanceof Error ? error : new Error(String(error)));
|
|
158
|
+
});
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
async function pipeOutput(stream: ReadableStream<Uint8Array>): Promise<void> {
|
|
162
|
+
const reader = stream.getReader();
|
|
163
|
+
const decoder = new TextDecoder();
|
|
164
|
+
try {
|
|
165
|
+
while (true) {
|
|
166
|
+
const { value, done } = await reader.read();
|
|
167
|
+
if (done) break;
|
|
168
|
+
const text = decoder.decode(value, { stream: true });
|
|
169
|
+
if (text) handleProcessOutput(text);
|
|
170
|
+
}
|
|
171
|
+
const rest = decoder.decode();
|
|
172
|
+
if (rest) handleProcessOutput(rest);
|
|
173
|
+
} catch {
|
|
174
|
+
// Process shutdown closes streams underneath us.
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function handleProcessOutput(data: string): void {
|
|
179
|
+
appendOutput(data);
|
|
180
|
+
respondToTerminalQueries(data);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function appendOutput(data: string): void {
|
|
184
|
+
outputBuffer.push(data);
|
|
185
|
+
while (outputBuffer.join("").length > 200_000) outputBuffer.shift();
|
|
186
|
+
broadcast({ type: "output", data });
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function writeInput(data: string): void {
|
|
190
|
+
if (isCursorPositionReport(data)) return;
|
|
191
|
+
|
|
192
|
+
if (startupInput && data === startupInput) {
|
|
193
|
+
if (remoteCommandStarted) return;
|
|
194
|
+
remoteCommandStarted = true;
|
|
195
|
+
broadcast({
|
|
196
|
+
type: "status",
|
|
197
|
+
status: `Running ${request.remoteCommand}`,
|
|
198
|
+
canFinish: true,
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
writeProcessInput(data);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function writeProcessInput(data: string): void {
|
|
206
|
+
try {
|
|
207
|
+
stdin?.write(new TextEncoder().encode(data));
|
|
208
|
+
stdin?.flush?.();
|
|
209
|
+
} catch {
|
|
210
|
+
// The process may have exited between the browser input event and this write.
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function respondToTerminalQueries(data: string): void {
|
|
215
|
+
terminalQueryBuffer += data;
|
|
216
|
+
|
|
217
|
+
while (true) {
|
|
218
|
+
const match = /\x1b\[(\??)6n/.exec(terminalQueryBuffer);
|
|
219
|
+
if (!match) break;
|
|
220
|
+
|
|
221
|
+
const prefix = match[1] ?? "";
|
|
222
|
+
writeProcessInput(`\x1b[${prefix}${terminalRows};${terminalCols}R`);
|
|
223
|
+
terminalQueryBuffer = terminalQueryBuffer.slice(match.index + match[0].length);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (terminalQueryBuffer.length > 16) {
|
|
227
|
+
terminalQueryBuffer = terminalQueryBuffer.slice(-16);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function requestFinish(): void {
|
|
232
|
+
if (settled) return;
|
|
233
|
+
settled = true;
|
|
234
|
+
broadcast({ type: "status", status: "Done. You can close this page now.", canFinish: false });
|
|
235
|
+
complete({ finished: true });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
function sendStatus(ws: ServerWebSocket<SocketData>): void {
|
|
239
|
+
if (settled) {
|
|
240
|
+
send(ws, { type: "status", status: "Done. You can close this page now.", canFinish: false });
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (processExitCode !== undefined) {
|
|
244
|
+
send(ws, {
|
|
245
|
+
type: "status",
|
|
246
|
+
status: processExitCode === 0 ? "Shell exited" : `Shell exited ${processExitCode}`,
|
|
247
|
+
exitCode: processExitCode,
|
|
248
|
+
canFinish: processExitCode === 0,
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (remoteCommandStarted) {
|
|
253
|
+
send(ws, { type: "status", status: `Running ${request.remoteCommand}`, canFinish: true });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
send(ws, { type: "status", status: proc ? "Connected" : "Starting", canFinish: !request.remoteCommand });
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function broadcast(message: ServerMessage): void {
|
|
260
|
+
for (const socket of sockets) send(socket, message);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function parseClientMessage(raw: string | Buffer): ClientMessage | undefined {
|
|
265
|
+
if (typeof raw !== "string") return undefined;
|
|
266
|
+
try {
|
|
267
|
+
const value = JSON.parse(raw) as ClientMessage;
|
|
268
|
+
if (value.type === "finish") return value;
|
|
269
|
+
if (value.type === "input" && typeof value.data === "string") return value;
|
|
270
|
+
if (
|
|
271
|
+
value.type === "resize" &&
|
|
272
|
+
Number.isInteger(value.cols) &&
|
|
273
|
+
Number.isInteger(value.rows) &&
|
|
274
|
+
value.cols > 0 &&
|
|
275
|
+
value.rows > 0
|
|
276
|
+
) {
|
|
277
|
+
return value;
|
|
278
|
+
}
|
|
279
|
+
} catch {
|
|
280
|
+
return undefined;
|
|
281
|
+
}
|
|
282
|
+
return undefined;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
function isCursorPositionReport(data: string): boolean {
|
|
286
|
+
return /^\x1b\[\??\d+;\d+R$/.test(data);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function send(ws: ServerWebSocket<SocketData>, message: ServerMessage): void {
|
|
290
|
+
ws.send(JSON.stringify(message));
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function ensureTrailingNewline(value: string): string {
|
|
294
|
+
return value.endsWith("\n") ? value : `${value}\n`;
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function htmlResponse(body: string): Response {
|
|
298
|
+
return new Response(body, {
|
|
299
|
+
headers: {
|
|
300
|
+
"content-type": "text/html; charset=utf-8",
|
|
301
|
+
"content-security-policy": [
|
|
302
|
+
"default-src 'none'",
|
|
303
|
+
"script-src 'unsafe-inline' 'unsafe-eval' 'wasm-unsafe-eval' https://esm.sh",
|
|
304
|
+
"style-src 'unsafe-inline'",
|
|
305
|
+
"connect-src 'self' ws: wss: https://esm.sh",
|
|
306
|
+
"form-action 'self'",
|
|
307
|
+
].join("; "),
|
|
308
|
+
},
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
function renderInteractionPage(
|
|
313
|
+
request: FreestyleTerminalSessionRequest,
|
|
314
|
+
options: { completed?: boolean; startupInput?: string } = {},
|
|
315
|
+
): string {
|
|
316
|
+
const completed = options.completed ?? false;
|
|
317
|
+
const escapedTitle = escapeHtml(completed ? "Interactive task completed" : request.title);
|
|
318
|
+
const escapedNode = escapeHtml(request.nodePath ?? "provider");
|
|
319
|
+
const escapedLabel = escapeHtml(request.title);
|
|
320
|
+
const escapedInstructions = request.instructions ? escapeHtml(request.instructions) : "";
|
|
321
|
+
const escapedCommand = escapeHtml(request.remoteCommand ?? request.command);
|
|
322
|
+
const startupInputLiteral = javaScriptLiteral(options.startupInput ?? null);
|
|
323
|
+
|
|
324
|
+
return `<!doctype html>
|
|
325
|
+
<html lang="en">
|
|
326
|
+
<head>
|
|
327
|
+
<meta charset="utf-8">
|
|
328
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
329
|
+
<title>${escapedTitle}</title>
|
|
330
|
+
<style>
|
|
331
|
+
:root {
|
|
332
|
+
color-scheme: dark;
|
|
333
|
+
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
334
|
+
background: #0a0a0a;
|
|
335
|
+
color: #f5f5f5;
|
|
336
|
+
}
|
|
337
|
+
body {
|
|
338
|
+
margin: 0;
|
|
339
|
+
height: 100vh;
|
|
340
|
+
padding: 24px;
|
|
341
|
+
display: grid;
|
|
342
|
+
place-items: center;
|
|
343
|
+
box-sizing: border-box;
|
|
344
|
+
overflow: hidden;
|
|
345
|
+
background:
|
|
346
|
+
radial-gradient(circle at 18% 0%, rgba(82, 82, 91, 0.16), transparent 28%),
|
|
347
|
+
#0a0a0a;
|
|
348
|
+
}
|
|
349
|
+
.terminal-window {
|
|
350
|
+
width: min(1120px, 100%);
|
|
351
|
+
height: min(760px, calc(100vh - 48px));
|
|
352
|
+
min-height: 420px;
|
|
353
|
+
display: grid;
|
|
354
|
+
grid-template-rows: auto auto minmax(0, 1fr) auto;
|
|
355
|
+
overflow: hidden;
|
|
356
|
+
border: 1px solid #2b2b2f;
|
|
357
|
+
border-radius: 8px;
|
|
358
|
+
background: #0b0f14;
|
|
359
|
+
box-shadow: 0 24px 70px rgba(0, 0, 0, 0.42);
|
|
360
|
+
}
|
|
361
|
+
.titlebar {
|
|
362
|
+
min-height: 50px;
|
|
363
|
+
display: flex;
|
|
364
|
+
align-items: center;
|
|
365
|
+
gap: 14px;
|
|
366
|
+
padding: 11px 14px;
|
|
367
|
+
border-bottom: 1px solid #27272a;
|
|
368
|
+
background: linear-gradient(#1c1c20, #17171a);
|
|
369
|
+
box-sizing: border-box;
|
|
370
|
+
}
|
|
371
|
+
.lights {
|
|
372
|
+
display: flex;
|
|
373
|
+
gap: 7px;
|
|
374
|
+
flex: 0 0 auto;
|
|
375
|
+
}
|
|
376
|
+
.light {
|
|
377
|
+
width: 11px;
|
|
378
|
+
height: 11px;
|
|
379
|
+
border-radius: 999px;
|
|
380
|
+
background: #3f3f46;
|
|
381
|
+
box-shadow: inset 0 0 0 1px rgba(255,255,255,0.08);
|
|
382
|
+
}
|
|
383
|
+
.light.red {
|
|
384
|
+
background: #ff5f57;
|
|
385
|
+
}
|
|
386
|
+
.light.yellow {
|
|
387
|
+
background: #febc2e;
|
|
388
|
+
}
|
|
389
|
+
.light.green {
|
|
390
|
+
background: #28c840;
|
|
391
|
+
}
|
|
392
|
+
.title-copy {
|
|
393
|
+
min-width: 0;
|
|
394
|
+
flex: 1;
|
|
395
|
+
}
|
|
396
|
+
.meta {
|
|
397
|
+
margin: 0 0 3px;
|
|
398
|
+
color: #a1a1aa;
|
|
399
|
+
font-size: 12px;
|
|
400
|
+
}
|
|
401
|
+
h1 {
|
|
402
|
+
margin: 0;
|
|
403
|
+
font-size: 14px;
|
|
404
|
+
line-height: 1.25;
|
|
405
|
+
font-weight: 600;
|
|
406
|
+
letter-spacing: 0;
|
|
407
|
+
white-space: nowrap;
|
|
408
|
+
overflow: hidden;
|
|
409
|
+
text-overflow: ellipsis;
|
|
410
|
+
}
|
|
411
|
+
.instructions {
|
|
412
|
+
margin: 4px 0 0;
|
|
413
|
+
white-space: pre-wrap;
|
|
414
|
+
color: #a1a1aa;
|
|
415
|
+
line-height: 1.35;
|
|
416
|
+
font-size: 12px;
|
|
417
|
+
}
|
|
418
|
+
.command {
|
|
419
|
+
margin: 0;
|
|
420
|
+
padding: 8px 12px;
|
|
421
|
+
border-bottom: 1px solid #1f2937;
|
|
422
|
+
background: #0f1720;
|
|
423
|
+
color: #7dd3fc;
|
|
424
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
425
|
+
font-size: 12px;
|
|
426
|
+
overflow-wrap: anywhere;
|
|
427
|
+
}
|
|
428
|
+
.terminal-shell {
|
|
429
|
+
min-height: 0;
|
|
430
|
+
height: 100%;
|
|
431
|
+
position: relative;
|
|
432
|
+
overflow: hidden;
|
|
433
|
+
background: #0b0f14;
|
|
434
|
+
user-select: text;
|
|
435
|
+
}
|
|
436
|
+
#terminal {
|
|
437
|
+
position: absolute;
|
|
438
|
+
inset: 0;
|
|
439
|
+
border-radius: 0;
|
|
440
|
+
box-shadow: none;
|
|
441
|
+
user-select: text;
|
|
442
|
+
--term-bg: #0b0f14;
|
|
443
|
+
--term-fg: #e5e7eb;
|
|
444
|
+
--term-cursor: #f8fafc;
|
|
445
|
+
--term-font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
446
|
+
--term-font-size: 13px;
|
|
447
|
+
--term-row-height: 17px;
|
|
448
|
+
--term-color-0: #1f2937;
|
|
449
|
+
--term-color-1: #ef4444;
|
|
450
|
+
--term-color-2: #22c55e;
|
|
451
|
+
--term-color-3: #eab308;
|
|
452
|
+
--term-color-4: #38bdf8;
|
|
453
|
+
--term-color-5: #a78bfa;
|
|
454
|
+
--term-color-6: #2dd4bf;
|
|
455
|
+
--term-color-7: #e5e7eb;
|
|
456
|
+
--term-color-8: #6b7280;
|
|
457
|
+
--term-color-9: #f87171;
|
|
458
|
+
--term-color-10: #4ade80;
|
|
459
|
+
--term-color-11: #facc15;
|
|
460
|
+
--term-color-12: #7dd3fc;
|
|
461
|
+
--term-color-13: #c4b5fd;
|
|
462
|
+
--term-color-14: #5eead4;
|
|
463
|
+
--term-color-15: #ffffff;
|
|
464
|
+
}
|
|
465
|
+
#terminal:not(.ready) {
|
|
466
|
+
visibility: hidden;
|
|
467
|
+
}
|
|
468
|
+
#fallback {
|
|
469
|
+
position: absolute;
|
|
470
|
+
inset: 0;
|
|
471
|
+
z-index: 1;
|
|
472
|
+
box-sizing: border-box;
|
|
473
|
+
margin: 0;
|
|
474
|
+
padding: 14px;
|
|
475
|
+
overflow: auto;
|
|
476
|
+
white-space: pre-wrap;
|
|
477
|
+
overflow-wrap: anywhere;
|
|
478
|
+
background: #0b0f14;
|
|
479
|
+
color: #e5e7eb;
|
|
480
|
+
user-select: text;
|
|
481
|
+
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
|
|
482
|
+
font-size: 13px;
|
|
483
|
+
line-height: 1.35;
|
|
484
|
+
}
|
|
485
|
+
#fallback.hidden {
|
|
486
|
+
display: none;
|
|
487
|
+
}
|
|
488
|
+
.wterm {
|
|
489
|
+
position: relative;
|
|
490
|
+
background: var(--term-bg);
|
|
491
|
+
color: var(--term-fg);
|
|
492
|
+
font-family: var(--term-font-family);
|
|
493
|
+
font-size: var(--term-font-size);
|
|
494
|
+
line-height: 1.2;
|
|
495
|
+
padding: 12px;
|
|
496
|
+
outline: none;
|
|
497
|
+
overflow: auto;
|
|
498
|
+
user-select: text;
|
|
499
|
+
}
|
|
500
|
+
.term-grid {
|
|
501
|
+
display: block;
|
|
502
|
+
white-space: pre;
|
|
503
|
+
contain: layout paint style;
|
|
504
|
+
user-select: text;
|
|
505
|
+
}
|
|
506
|
+
.term-row {
|
|
507
|
+
display: block;
|
|
508
|
+
height: var(--term-row-height);
|
|
509
|
+
line-height: var(--term-row-height);
|
|
510
|
+
user-select: text;
|
|
511
|
+
}
|
|
512
|
+
.term-row > span {
|
|
513
|
+
display: inline-block;
|
|
514
|
+
height: var(--term-row-height);
|
|
515
|
+
vertical-align: top;
|
|
516
|
+
user-select: text;
|
|
517
|
+
}
|
|
518
|
+
.term-block {
|
|
519
|
+
width: 1ch;
|
|
520
|
+
overflow: hidden;
|
|
521
|
+
}
|
|
522
|
+
.term-cursor {
|
|
523
|
+
outline: 1px solid var(--term-cursor);
|
|
524
|
+
outline-offset: -1px;
|
|
525
|
+
}
|
|
526
|
+
.wterm.focused .term-cursor {
|
|
527
|
+
background: var(--term-cursor);
|
|
528
|
+
color: var(--term-bg);
|
|
529
|
+
outline: none;
|
|
530
|
+
}
|
|
531
|
+
footer {
|
|
532
|
+
display: flex;
|
|
533
|
+
align-items: center;
|
|
534
|
+
gap: 14px;
|
|
535
|
+
padding: 11px 14px;
|
|
536
|
+
border-top: 1px solid #27272a;
|
|
537
|
+
background: #17171a;
|
|
538
|
+
}
|
|
539
|
+
#status {
|
|
540
|
+
flex: 1;
|
|
541
|
+
min-width: 0;
|
|
542
|
+
color: #cbd5e1;
|
|
543
|
+
font-size: 12px;
|
|
544
|
+
}
|
|
545
|
+
button {
|
|
546
|
+
border: 0;
|
|
547
|
+
border-radius: 6px;
|
|
548
|
+
background: #f5f5f5;
|
|
549
|
+
color: #111111;
|
|
550
|
+
min-width: 82px;
|
|
551
|
+
cursor: pointer;
|
|
552
|
+
font: inherit;
|
|
553
|
+
font-size: 12px;
|
|
554
|
+
font-weight: 600;
|
|
555
|
+
padding: 8px 12px;
|
|
556
|
+
}
|
|
557
|
+
button:hover:not(:disabled) {
|
|
558
|
+
background: #ffffff;
|
|
559
|
+
}
|
|
560
|
+
button:disabled {
|
|
561
|
+
cursor: not-allowed;
|
|
562
|
+
opacity: 0.45;
|
|
563
|
+
}
|
|
564
|
+
@media (max-width: 720px) {
|
|
565
|
+
body {
|
|
566
|
+
padding: 0;
|
|
567
|
+
}
|
|
568
|
+
.terminal-window {
|
|
569
|
+
height: 100vh;
|
|
570
|
+
min-height: 100vh;
|
|
571
|
+
border: 0;
|
|
572
|
+
border-radius: 0;
|
|
573
|
+
}
|
|
574
|
+
.instructions {
|
|
575
|
+
display: none;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
</style>
|
|
579
|
+
</head>
|
|
580
|
+
<body>
|
|
581
|
+
<section class="terminal-window">
|
|
582
|
+
<header class="titlebar">
|
|
583
|
+
<div class="lights" aria-hidden="true">
|
|
584
|
+
<span class="light red"></span>
|
|
585
|
+
<span class="light yellow"></span>
|
|
586
|
+
<span class="light green"></span>
|
|
587
|
+
</div>
|
|
588
|
+
<div class="title-copy">
|
|
589
|
+
<p class="meta">rigkit node ${escapedNode}</p>
|
|
590
|
+
<h1>${escapedLabel}</h1>
|
|
591
|
+
${escapedInstructions ? `<p class="instructions">${escapedInstructions}</p>` : ""}
|
|
592
|
+
</div>
|
|
593
|
+
</header>
|
|
594
|
+
<p class="command">$ ${escapedCommand}</p>
|
|
595
|
+
<main class="terminal-shell" aria-label="Interactive terminal">
|
|
596
|
+
<pre id="fallback">Starting terminal...\n</pre>
|
|
597
|
+
<div id="terminal"></div>
|
|
598
|
+
</main>
|
|
599
|
+
<footer>
|
|
600
|
+
<span id="status">${completed ? "Done. You can close this page now." : "Starting terminal"}</span>
|
|
601
|
+
<button id="finish" type="button" disabled>Finished</button>
|
|
602
|
+
</footer>
|
|
603
|
+
</section>
|
|
604
|
+
<script type="module">
|
|
605
|
+
const token = new URLSearchParams(location.search).get("token") || "";
|
|
606
|
+
const terminalUrl = new URL("/terminal", location.href);
|
|
607
|
+
terminalUrl.protocol = location.protocol === "https:" ? "wss:" : "ws:";
|
|
608
|
+
terminalUrl.searchParams.set("token", token);
|
|
609
|
+
const statusEl = document.getElementById("status");
|
|
610
|
+
const finishEl = document.getElementById("finish");
|
|
611
|
+
const terminalEl = document.getElementById("terminal");
|
|
612
|
+
const fallbackEl = document.getElementById("fallback");
|
|
613
|
+
const outputBacklog = [];
|
|
614
|
+
let socket;
|
|
615
|
+
let term;
|
|
616
|
+
let termReady = false;
|
|
617
|
+
const startupInput = ${startupInputLiteral};
|
|
618
|
+
let startupSent = false;
|
|
619
|
+
let startupIdleTimer;
|
|
620
|
+
let startupMaxTimer;
|
|
621
|
+
|
|
622
|
+
function sendTerminalInput(data) {
|
|
623
|
+
if (!data || socket?.readyState !== WebSocket.OPEN) return;
|
|
624
|
+
socket.send(JSON.stringify({ type: "input", data }));
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
function setStatus(text, canFinish = false) {
|
|
628
|
+
statusEl.textContent = text;
|
|
629
|
+
finishEl.disabled = !canFinish;
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
function appendFallback(data) {
|
|
633
|
+
fallbackEl.textContent += data;
|
|
634
|
+
fallbackEl.scrollTop = fallbackEl.scrollHeight;
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function sendStartupInput() {
|
|
638
|
+
if (!startupInput || startupSent || socket.readyState !== WebSocket.OPEN) return;
|
|
639
|
+
startupSent = true;
|
|
640
|
+
clearTimeout(startupIdleTimer);
|
|
641
|
+
clearTimeout(startupMaxTimer);
|
|
642
|
+
sendTerminalInput(startupInput);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function scheduleStartupInput(delay = 350) {
|
|
646
|
+
if (!startupInput || startupSent || socket.readyState !== WebSocket.OPEN) return;
|
|
647
|
+
clearTimeout(startupIdleTimer);
|
|
648
|
+
startupIdleTimer = setTimeout(sendStartupInput, delay);
|
|
649
|
+
startupMaxTimer ??= setTimeout(sendStartupInput, 1500);
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
socket = new WebSocket(terminalUrl);
|
|
653
|
+
socket.addEventListener("open", () => {
|
|
654
|
+
setStatus("Connected");
|
|
655
|
+
scheduleStartupInput(700);
|
|
656
|
+
});
|
|
657
|
+
socket.addEventListener("message", (event) => {
|
|
658
|
+
const message = JSON.parse(event.data);
|
|
659
|
+
if (message.type === "output") {
|
|
660
|
+
outputBacklog.push(message.data);
|
|
661
|
+
if (termReady) {
|
|
662
|
+
term.write(message.data);
|
|
663
|
+
} else {
|
|
664
|
+
appendFallback(message.data);
|
|
665
|
+
}
|
|
666
|
+
scheduleStartupInput();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (message.type === "status") {
|
|
670
|
+
setStatus(message.status, Boolean(message.canFinish));
|
|
671
|
+
}
|
|
672
|
+
});
|
|
673
|
+
socket.addEventListener("close", () => {
|
|
674
|
+
if (finishEl.disabled) setStatus("Terminal connection closed");
|
|
675
|
+
});
|
|
676
|
+
finishEl.addEventListener("click", () => {
|
|
677
|
+
if (socket?.readyState === WebSocket.OPEN) {
|
|
678
|
+
socket.send(JSON.stringify({ type: "finish" }));
|
|
679
|
+
} else {
|
|
680
|
+
fetch("/complete?token=" + encodeURIComponent(token), { method: "POST" }).catch(() => {});
|
|
681
|
+
}
|
|
682
|
+
setStatus("Finishing");
|
|
683
|
+
finishEl.disabled = true;
|
|
684
|
+
});
|
|
685
|
+
document.addEventListener("keydown", (event) => {
|
|
686
|
+
if (event.defaultPrevented || isTextEditingTarget(event.target)) return;
|
|
687
|
+
const data = keyEventToTerminalInput(event);
|
|
688
|
+
if (!data) return;
|
|
689
|
+
event.preventDefault();
|
|
690
|
+
event.stopImmediatePropagation();
|
|
691
|
+
term?.focus();
|
|
692
|
+
sendTerminalInput(data);
|
|
693
|
+
}, { capture: true });
|
|
694
|
+
|
|
695
|
+
try {
|
|
696
|
+
const [{ WTerm }, { GhosttyCore }] = await Promise.all([
|
|
697
|
+
import("https://esm.sh/@wterm/dom@0.3.0?bundle"),
|
|
698
|
+
import("https://esm.sh/@wterm/ghostty@0.3.0?bundle"),
|
|
699
|
+
]);
|
|
700
|
+
const core = await GhosttyCore.load({
|
|
701
|
+
wasmPath: "https://esm.sh/@wterm/ghostty@0.3.0/wasm/ghostty-vt.wasm",
|
|
702
|
+
});
|
|
703
|
+
term = new WTerm(terminalEl, {
|
|
704
|
+
core,
|
|
705
|
+
cols: 100,
|
|
706
|
+
rows: 28,
|
|
707
|
+
autoResize: true,
|
|
708
|
+
cursorBlink: true,
|
|
709
|
+
onData(data) {
|
|
710
|
+
sendTerminalInput(data);
|
|
711
|
+
},
|
|
712
|
+
onResize(cols, rows) {
|
|
713
|
+
if (socket.readyState === WebSocket.OPEN) {
|
|
714
|
+
socket.send(JSON.stringify({ type: "resize", cols, rows }));
|
|
715
|
+
}
|
|
716
|
+
},
|
|
717
|
+
});
|
|
718
|
+
await term.init();
|
|
719
|
+
for (const chunk of outputBacklog) term.write(chunk);
|
|
720
|
+
termReady = true;
|
|
721
|
+
terminalEl.classList.add("ready");
|
|
722
|
+
fallbackEl.classList.add("hidden");
|
|
723
|
+
term.focus();
|
|
724
|
+
} catch (error) {
|
|
725
|
+
console.error(error);
|
|
726
|
+
appendFallback("\\nUnable to load the libghostty renderer. Output will continue here.\\n");
|
|
727
|
+
setStatus("Renderer unavailable. Command output is shown in fallback mode.", !startupInput || startupSent);
|
|
728
|
+
}
|
|
729
|
+
|
|
730
|
+
function isTextEditingTarget(target) {
|
|
731
|
+
if (!(target instanceof Element)) return false;
|
|
732
|
+
if (terminalEl.contains(target)) return false;
|
|
733
|
+
if (target === finishEl) return true;
|
|
734
|
+
return Boolean(target.closest("textarea, input, select, button, [contenteditable=''], [contenteditable='true']"));
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
function keyEventToTerminalInput(event) {
|
|
738
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "c") {
|
|
739
|
+
const selection = window.getSelection();
|
|
740
|
+
if (selection && selection.toString().length > 0) return null;
|
|
741
|
+
}
|
|
742
|
+
if ((event.metaKey || event.ctrlKey) && event.key === "v") return null;
|
|
743
|
+
if (event.metaKey && !event.ctrlKey) return null;
|
|
744
|
+
|
|
745
|
+
if (event.ctrlKey && !event.altKey && !event.metaKey) {
|
|
746
|
+
if (event.key.length === 1) {
|
|
747
|
+
const code = event.key.toLowerCase().charCodeAt(0);
|
|
748
|
+
if (code >= 97 && code <= 122) return String.fromCharCode(code - 96);
|
|
749
|
+
}
|
|
750
|
+
if (event.key === "[") return "\\x1b";
|
|
751
|
+
if (event.key === "\\\\") return "\\x1c";
|
|
752
|
+
if (event.key === "]") return "\\x1d";
|
|
753
|
+
if (event.key === "^") return "\\x1e";
|
|
754
|
+
if (event.key === "_") return "\\x1f";
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
if (event.key === "Enter" && event.shiftKey) return "\\x1b[13;2u";
|
|
758
|
+
if (event.key === "Tab" && event.shiftKey) return "\\x1b[Z";
|
|
759
|
+
|
|
760
|
+
const fixed = {
|
|
761
|
+
Enter: "\\r",
|
|
762
|
+
Backspace: "\\x7f",
|
|
763
|
+
Tab: "\\t",
|
|
764
|
+
Escape: "\\x1b",
|
|
765
|
+
Insert: "\\x1b[2~",
|
|
766
|
+
Delete: "\\x1b[3~",
|
|
767
|
+
PageUp: "\\x1b[5~",
|
|
768
|
+
PageDown: "\\x1b[6~",
|
|
769
|
+
F1: "\\x1bOP",
|
|
770
|
+
F2: "\\x1bOQ",
|
|
771
|
+
F3: "\\x1bOR",
|
|
772
|
+
F4: "\\x1bOS",
|
|
773
|
+
F5: "\\x1b[15~",
|
|
774
|
+
F6: "\\x1b[17~",
|
|
775
|
+
F7: "\\x1b[18~",
|
|
776
|
+
F8: "\\x1b[19~",
|
|
777
|
+
F9: "\\x1b[20~",
|
|
778
|
+
F10: "\\x1b[21~",
|
|
779
|
+
F11: "\\x1b[23~",
|
|
780
|
+
F12: "\\x1b[24~",
|
|
781
|
+
};
|
|
782
|
+
if (fixed[event.key]) return event.altKey ? "\\x1b" + fixed[event.key] : fixed[event.key];
|
|
783
|
+
|
|
784
|
+
const navigation = {
|
|
785
|
+
ArrowUp: "\\x1b[A",
|
|
786
|
+
ArrowDown: "\\x1b[B",
|
|
787
|
+
ArrowRight: "\\x1b[C",
|
|
788
|
+
ArrowLeft: "\\x1b[D",
|
|
789
|
+
Home: "\\x1b[H",
|
|
790
|
+
End: "\\x1b[F",
|
|
791
|
+
};
|
|
792
|
+
if (navigation[event.key]) return event.altKey ? "\\x1b" + navigation[event.key] : navigation[event.key];
|
|
793
|
+
|
|
794
|
+
if (event.key.length === 1 && !event.ctrlKey && !event.metaKey) {
|
|
795
|
+
return event.altKey ? "\\x1b" + event.key : event.key;
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
</script>
|
|
801
|
+
</body>
|
|
802
|
+
</html>`;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function javaScriptLiteral(value: string | null): string {
|
|
806
|
+
return JSON.stringify(value)
|
|
807
|
+
.replaceAll("<", "\\u003c")
|
|
808
|
+
.replaceAll(">", "\\u003e")
|
|
809
|
+
.replaceAll("&", "\\u0026")
|
|
810
|
+
.replaceAll("\u2028", "\\u2028")
|
|
811
|
+
.replaceAll("\u2029", "\\u2029");
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
function escapeHtml(value: string): string {
|
|
815
|
+
return value
|
|
816
|
+
.replaceAll("&", "&")
|
|
817
|
+
.replaceAll("<", "<")
|
|
818
|
+
.replaceAll(">", ">")
|
|
819
|
+
.replaceAll('"', """)
|
|
820
|
+
.replaceAll("'", "'");
|
|
821
|
+
}
|