hop2 0.9.0 → 0.10.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/AGENTS.md +33 -0
- package/INTEGRATION.md +77 -85
- package/MULTI-AGENT-CHECKLIST.md +37 -0
- package/README-MCP.md +232 -0
- package/README.md +209 -148
- package/demo/30s-product-demo.md +98 -0
- package/demo/README.md +106 -0
- package/demo/director.mjs +236 -0
- package/demo/drive-hop-terminal.mjs +70 -0
- package/demo/hop-demo-lib.mjs +525 -0
- package/demo/record-hop-view.mjs +26 -0
- package/demo/static-preview-server.mjs +45 -0
- package/demo/stitch-demo-video.mjs +128 -0
- package/hay/CHANGELOG.md +6 -0
- package/hay/DEV_NOTES.md +25 -0
- package/hay/README.md +136 -198
- package/hay/apps/cli/src/index.ts +321 -67
- package/hay/apps/server/src/index.ts +4 -3
- package/hay/apps/server/src/lib.ts +5 -4
- package/hay/apps/server/src/pty.ts +20 -7
- package/hay/apps/server/src/rooms.ts +226 -12
- package/hay/apps/server/test/rooms.test.ts +82 -3
- package/hay/apps/web/src/App.tsx +487 -112
- package/hay/apps/web/src/components/MobileKeyboard.tsx +43 -2
- package/hay/apps/web/src/styles.css +259 -29
- package/hay/package-lock.json +4 -4
- package/hay/packages/shared/src/protocol.ts +8 -0
- package/hay-web/assets/index-BmWjq_Nh.css +32 -0
- package/hay-web/assets/index-D9aOC5Zt.js +63 -0
- package/hay-web/assets/{index-FDfdolCs.js.map → index-D9aOC5Zt.js.map} +1 -1
- package/hay-web/index.html +2 -2
- package/hop +6801 -1714
- package/lib/naming-themes.js +194 -0
- package/lib/terminal-access.js +17 -0
- package/mcp/hop-mcp.js +4730 -0
- package/package.json +6 -3
- package/scripts/hay-host.js +189 -0
- package/sessions.html +378 -26
- package/setup-mcp.js +443 -0
- package/tests/hop-mcp.integration.test.cjs +3014 -0
- package/tests/terminal-access.unit.test.cjs +21 -0
- package/tests/terminal-api.integration.test.cjs +298 -0
- package/.hop-tmux.conf +0 -18
- package/hay-web/assets/index-CTTzApRp.css +0 -32
- package/hay-web/assets/index-FDfdolCs.js +0 -60
- package/tunnel-shell.js +0 -254
|
@@ -0,0 +1,525 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import net from "node:net";
|
|
5
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
6
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
+
import { chromium, devices } from "../hay/node_modules/playwright/index.mjs";
|
|
8
|
+
|
|
9
|
+
const DEFAULT_HOP_HOME = process.env.HOP_HOME || path.join(process.env.HOME, ".hop2");
|
|
10
|
+
const DEFAULT_CHROME_PATH = "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome";
|
|
11
|
+
const REPO_ROOT = path.resolve(path.dirname(new URL(import.meta.url).pathname), "..");
|
|
12
|
+
|
|
13
|
+
export async function sleep(ms) {
|
|
14
|
+
await delay(ms);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function parseArgs(argv) {
|
|
18
|
+
const args = {};
|
|
19
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
20
|
+
const token = argv[i];
|
|
21
|
+
if (!token.startsWith("--")) continue;
|
|
22
|
+
const key = token.slice(2);
|
|
23
|
+
const next = argv[i + 1];
|
|
24
|
+
if (!next || next.startsWith("--")) {
|
|
25
|
+
args[key] = true;
|
|
26
|
+
continue;
|
|
27
|
+
}
|
|
28
|
+
args[key] = next;
|
|
29
|
+
i += 1;
|
|
30
|
+
}
|
|
31
|
+
return args;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function getHopState(hopHome = DEFAULT_HOP_HOME) {
|
|
35
|
+
const statePath = path.join(hopHome, ".tunnel-state");
|
|
36
|
+
const secretPath = path.join(hopHome, ".session_secret");
|
|
37
|
+
if (!fs.existsSync(statePath)) {
|
|
38
|
+
throw new Error(`Hop state file not found: ${statePath}`);
|
|
39
|
+
}
|
|
40
|
+
if (!fs.existsSync(secretPath)) {
|
|
41
|
+
throw new Error(`Hop session secret file not found: ${secretPath}`);
|
|
42
|
+
}
|
|
43
|
+
const state = JSON.parse(fs.readFileSync(statePath, "utf8"));
|
|
44
|
+
const sessionSecret = fs.readFileSync(secretPath, "utf8").trim();
|
|
45
|
+
if (!state.port) {
|
|
46
|
+
throw new Error(`Invalid Hop state file: missing port in ${statePath}`);
|
|
47
|
+
}
|
|
48
|
+
return {
|
|
49
|
+
hopHome,
|
|
50
|
+
port: Number(state.port),
|
|
51
|
+
publicUrl: state.url || null,
|
|
52
|
+
localUrl: `http://127.0.0.1:${state.port}`,
|
|
53
|
+
sessionSecret
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function ensureDir(dirPath) {
|
|
58
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
59
|
+
return dirPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function resetDir(dirPath) {
|
|
63
|
+
fs.rmSync(dirPath, { recursive: true, force: true });
|
|
64
|
+
fs.mkdirSync(dirPath, { recursive: true });
|
|
65
|
+
return dirPath;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function waitForFile(filePath, timeoutMs = 10000) {
|
|
69
|
+
const startedAt = Date.now();
|
|
70
|
+
while (!fs.existsSync(filePath)) {
|
|
71
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
72
|
+
throw new Error(`Timed out waiting for file: ${filePath}`);
|
|
73
|
+
}
|
|
74
|
+
await sleep(200);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function waitForHopReady(hopHome, timeoutMs = 10000) {
|
|
79
|
+
const statePath = path.join(hopHome, ".tunnel-state");
|
|
80
|
+
await waitForFile(statePath, timeoutMs);
|
|
81
|
+
const startedAt = Date.now();
|
|
82
|
+
while (true) {
|
|
83
|
+
const state = getHopState(hopHome);
|
|
84
|
+
try {
|
|
85
|
+
const response = await fetch(`${state.localUrl}/api/sessions`, {
|
|
86
|
+
headers: {
|
|
87
|
+
Authorization: `Bearer ${state.sessionSecret}`
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
if (response.ok) {
|
|
91
|
+
return state;
|
|
92
|
+
}
|
|
93
|
+
} catch {
|
|
94
|
+
// keep polling
|
|
95
|
+
}
|
|
96
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
97
|
+
throw new Error(`Timed out waiting for Hop API in ${hopHome}`);
|
|
98
|
+
}
|
|
99
|
+
await sleep(250);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function withNodePath(env = {}) {
|
|
104
|
+
const currentPath = process.env.PATH || "";
|
|
105
|
+
const nextPath = currentPath.includes("/opt/homebrew/bin")
|
|
106
|
+
? currentPath
|
|
107
|
+
: `/opt/homebrew/bin:${currentPath}`;
|
|
108
|
+
return {
|
|
109
|
+
...process.env,
|
|
110
|
+
...env,
|
|
111
|
+
PATH: nextPath
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function runHopCli(args, options = {}) {
|
|
116
|
+
const result = spawnSync("/opt/homebrew/bin/node", [path.join(REPO_ROOT, "hop"), ...args], {
|
|
117
|
+
cwd: REPO_ROOT,
|
|
118
|
+
env: withNodePath(options.env || {}),
|
|
119
|
+
encoding: "utf8",
|
|
120
|
+
stdio: options.stdio || "pipe"
|
|
121
|
+
});
|
|
122
|
+
if (result.status !== 0) {
|
|
123
|
+
const stderr = result.stderr || "";
|
|
124
|
+
const stdout = result.stdout || "";
|
|
125
|
+
throw new Error(`hop ${args.join(" ")} failed: ${stderr || stdout || result.status}`);
|
|
126
|
+
}
|
|
127
|
+
return result;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export async function startHopDaemon(hopHome) {
|
|
131
|
+
ensureDir(hopHome);
|
|
132
|
+
runHopCli(["start"], {
|
|
133
|
+
env: { HOP_HOME: hopHome },
|
|
134
|
+
stdio: "pipe"
|
|
135
|
+
});
|
|
136
|
+
return waitForHopReady(hopHome, 15000);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function stopHopDaemon(hopHome) {
|
|
140
|
+
try {
|
|
141
|
+
runHopCli(["stop"], {
|
|
142
|
+
env: { HOP_HOME: hopHome },
|
|
143
|
+
stdio: "pipe"
|
|
144
|
+
});
|
|
145
|
+
} catch {
|
|
146
|
+
// Best effort shutdown for demo homes.
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function prepareSanitizedWorkspace(workspaceDir, options = {}) {
|
|
151
|
+
const reset = options.reset !== false;
|
|
152
|
+
if (reset) {
|
|
153
|
+
resetDir(workspaceDir);
|
|
154
|
+
} else {
|
|
155
|
+
ensureDir(workspaceDir);
|
|
156
|
+
}
|
|
157
|
+
const readmePath = path.join(workspaceDir, "README.md");
|
|
158
|
+
const appPath = path.join(workspaceDir, "index.html");
|
|
159
|
+
if (!fs.existsSync(readmePath)) {
|
|
160
|
+
fs.writeFileSync(readmePath, `# Demo Project
|
|
161
|
+
|
|
162
|
+
This is a sanitized workspace for Hop product demos.
|
|
163
|
+
|
|
164
|
+
- No personal paths
|
|
165
|
+
- No personal usernames
|
|
166
|
+
- Safe to record on screen
|
|
167
|
+
`);
|
|
168
|
+
}
|
|
169
|
+
if (!fs.existsSync(appPath)) {
|
|
170
|
+
fs.writeFileSync(appPath, `<!doctype html>
|
|
171
|
+
<html lang="en">
|
|
172
|
+
<head>
|
|
173
|
+
<meta charset="utf-8">
|
|
174
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
175
|
+
<title>Hop Demo Workspace</title>
|
|
176
|
+
<style>
|
|
177
|
+
body { font-family: ui-sans-serif, system-ui, sans-serif; margin: 0; padding: 48px; background: linear-gradient(135deg, #f9f5ef, #efe6d6); color: #1f2937; }
|
|
178
|
+
.card { max-width: 720px; background: rgba(255,255,255,0.85); padding: 32px; border-radius: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.08); }
|
|
179
|
+
h1 { margin-top: 0; font-size: 44px; }
|
|
180
|
+
p { font-size: 18px; line-height: 1.6; }
|
|
181
|
+
</style>
|
|
182
|
+
</head>
|
|
183
|
+
<body>
|
|
184
|
+
<div class="card">
|
|
185
|
+
<h1>Hop Demo Workspace</h1>
|
|
186
|
+
<p>Visible, steerable agent terminals for real work.</p>
|
|
187
|
+
<p>This file exists to give the demo agent something clean to read and change.</p>
|
|
188
|
+
</div>
|
|
189
|
+
</body>
|
|
190
|
+
</html>
|
|
191
|
+
`);
|
|
192
|
+
}
|
|
193
|
+
return workspaceDir;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function buildSanitizedLaunchCommand(command, options = {}) {
|
|
197
|
+
const prompt = options.prompt || "demo@hop ~/workspace % ";
|
|
198
|
+
const homeDir = options.homeDir || path.join(os.tmpdir(), "hop-demo-home");
|
|
199
|
+
const cwdLabel = options.cwdLabel || "workspace";
|
|
200
|
+
ensureDir(homeDir);
|
|
201
|
+
return [
|
|
202
|
+
`export HOME=${JSON.stringify(homeDir)}`,
|
|
203
|
+
"export USER=demo",
|
|
204
|
+
"export LOGNAME=demo",
|
|
205
|
+
"export HOSTNAME=hop-demo",
|
|
206
|
+
"export TERM_PROGRAM=HopDemo",
|
|
207
|
+
"unset VIRTUAL_ENV CONDA_DEFAULT_ENV CONDA_PROMPT_MODIFIER",
|
|
208
|
+
`export PS1=${JSON.stringify(prompt)}`,
|
|
209
|
+
`PROMPT=${JSON.stringify(prompt)}`,
|
|
210
|
+
`RPROMPT=''`,
|
|
211
|
+
`cd ${JSON.stringify(options.cwd || ".")}`,
|
|
212
|
+
`printf '\\033]0;%s\\007' ${JSON.stringify(cwdLabel)}`,
|
|
213
|
+
"clear",
|
|
214
|
+
command
|
|
215
|
+
].join("; ");
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
export async function hopApi(state, pathname, options = {}) {
|
|
219
|
+
const method = options.method || "GET";
|
|
220
|
+
const headers = {
|
|
221
|
+
Authorization: `Bearer ${state.sessionSecret}`,
|
|
222
|
+
...options.headers
|
|
223
|
+
};
|
|
224
|
+
let body = options.body;
|
|
225
|
+
if (body && typeof body === "object" && !(body instanceof Buffer)) {
|
|
226
|
+
headers["Content-Type"] = "application/json";
|
|
227
|
+
body = JSON.stringify(body);
|
|
228
|
+
}
|
|
229
|
+
const response = await fetch(`${state.localUrl}${pathname}`, {
|
|
230
|
+
method,
|
|
231
|
+
headers,
|
|
232
|
+
body
|
|
233
|
+
});
|
|
234
|
+
const text = await response.text();
|
|
235
|
+
let payload = null;
|
|
236
|
+
if (text) {
|
|
237
|
+
try {
|
|
238
|
+
payload = JSON.parse(text);
|
|
239
|
+
} catch {
|
|
240
|
+
payload = { raw: text };
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
if (!response.ok) {
|
|
244
|
+
const detail = payload && payload.error ? payload.error : text || `${response.status}`;
|
|
245
|
+
throw new Error(`${method} ${pathname} failed: ${detail}`);
|
|
246
|
+
}
|
|
247
|
+
return payload;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export async function createSession(state, options) {
|
|
251
|
+
return hopApi(state, "/api/sessions", {
|
|
252
|
+
method: "POST",
|
|
253
|
+
body: options
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function deleteSession(state, internalName) {
|
|
258
|
+
return hopApi(state, "/api/sessions/delete", {
|
|
259
|
+
method: "POST",
|
|
260
|
+
body: { internalName }
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export async function createTerminal(state, options) {
|
|
265
|
+
return hopApi(state, "/api/terminals", {
|
|
266
|
+
method: "POST",
|
|
267
|
+
body: options
|
|
268
|
+
});
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
export async function attachTerminal(state, options) {
|
|
272
|
+
return hopApi(state, "/api/terminals/attach", {
|
|
273
|
+
method: "POST",
|
|
274
|
+
body: options
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
export async function ensureTerminal(state, options) {
|
|
279
|
+
try {
|
|
280
|
+
return await createTerminal(state, options);
|
|
281
|
+
} catch (error) {
|
|
282
|
+
if (!String(error.message || error).includes("Session name already in use")) {
|
|
283
|
+
throw error;
|
|
284
|
+
}
|
|
285
|
+
return attachTerminal(state, { name: options.name, cols: options.cols, rows: options.rows });
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export async function execTerminal(state, terminalId, command) {
|
|
290
|
+
return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/exec`, {
|
|
291
|
+
method: "POST",
|
|
292
|
+
body: { command }
|
|
293
|
+
});
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function writeTerminal(state, terminalId, data) {
|
|
297
|
+
return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/write`, {
|
|
298
|
+
method: "POST",
|
|
299
|
+
body: { data }
|
|
300
|
+
});
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
export async function resizeTerminal(state, terminalId, cols, rows) {
|
|
304
|
+
return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}/resize`, {
|
|
305
|
+
method: "POST",
|
|
306
|
+
body: { cols, rows }
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export async function closeTerminal(state, terminalId, killSession = false) {
|
|
311
|
+
return hopApi(state, `/api/terminals/${encodeURIComponent(terminalId)}?killSession=${killSession ? "true" : "false"}`, {
|
|
312
|
+
method: "DELETE"
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function waitForTerminalOutput(state, terminalId, options = {}) {
|
|
317
|
+
const timeoutMs = Number(options.timeoutMs || 15000);
|
|
318
|
+
const pattern = options.pattern ? String(options.pattern) : null;
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
321
|
+
const response = await fetch(`${state.localUrl}/api/terminals/${encodeURIComponent(terminalId)}/stream`, {
|
|
322
|
+
headers: {
|
|
323
|
+
Authorization: `Bearer ${state.sessionSecret}`
|
|
324
|
+
},
|
|
325
|
+
signal: controller.signal
|
|
326
|
+
});
|
|
327
|
+
if (!response.ok) {
|
|
328
|
+
clearTimeout(timer);
|
|
329
|
+
throw new Error(`Terminal stream failed: ${response.status}`);
|
|
330
|
+
}
|
|
331
|
+
const decoder = new TextDecoder();
|
|
332
|
+
let pending = "";
|
|
333
|
+
let text = "";
|
|
334
|
+
try {
|
|
335
|
+
for await (const chunk of response.body) {
|
|
336
|
+
pending += decoder.decode(chunk, { stream: true });
|
|
337
|
+
let boundary = pending.indexOf("\n\n");
|
|
338
|
+
while (boundary !== -1) {
|
|
339
|
+
const rawEvent = pending.slice(0, boundary);
|
|
340
|
+
pending = pending.slice(boundary + 2);
|
|
341
|
+
boundary = pending.indexOf("\n\n");
|
|
342
|
+
const dataLine = rawEvent
|
|
343
|
+
.split("\n")
|
|
344
|
+
.find((line) => line.startsWith("data: "));
|
|
345
|
+
if (!dataLine) continue;
|
|
346
|
+
const payload = JSON.parse(dataLine.slice(6));
|
|
347
|
+
if (payload.type !== "output" && payload.type !== "snapshot") continue;
|
|
348
|
+
const chunkText = typeof payload.data === "string" ? payload.data : "";
|
|
349
|
+
text += chunkText;
|
|
350
|
+
if (!pattern || text.includes(pattern)) {
|
|
351
|
+
clearTimeout(timer);
|
|
352
|
+
controller.abort();
|
|
353
|
+
return { payload, text };
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
} catch (error) {
|
|
358
|
+
if (error.name === "AbortError") {
|
|
359
|
+
clearTimeout(timer);
|
|
360
|
+
return { payload: null, text };
|
|
361
|
+
}
|
|
362
|
+
clearTimeout(timer);
|
|
363
|
+
throw error;
|
|
364
|
+
}
|
|
365
|
+
clearTimeout(timer);
|
|
366
|
+
return { payload: null, text };
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function buildCookie(state) {
|
|
370
|
+
return {
|
|
371
|
+
name: "tunnel_session",
|
|
372
|
+
value: state.sessionSecret,
|
|
373
|
+
domain: "127.0.0.1",
|
|
374
|
+
path: "/",
|
|
375
|
+
httpOnly: true,
|
|
376
|
+
secure: false,
|
|
377
|
+
sameSite: "Lax"
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
export async function openRecordedContext(state, options = {}) {
|
|
382
|
+
const outDir = options.outDir || path.join(process.cwd(), "demo-output");
|
|
383
|
+
fs.mkdirSync(outDir, { recursive: true });
|
|
384
|
+
const browser = await chromium.launch({
|
|
385
|
+
headless: options.headless !== false,
|
|
386
|
+
executablePath: options.chromePath || DEFAULT_CHROME_PATH
|
|
387
|
+
});
|
|
388
|
+
const useMobile = Boolean(options.mobile);
|
|
389
|
+
const contextOptions = useMobile
|
|
390
|
+
? {
|
|
391
|
+
...devices["iPhone 14"],
|
|
392
|
+
recordVideo: {
|
|
393
|
+
dir: outDir,
|
|
394
|
+
size: { width: 390, height: 844 }
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
: {
|
|
398
|
+
viewport: {
|
|
399
|
+
width: Number(options.width || 1920),
|
|
400
|
+
height: Number(options.height || 1080)
|
|
401
|
+
},
|
|
402
|
+
recordVideo: {
|
|
403
|
+
dir: outDir,
|
|
404
|
+
size: {
|
|
405
|
+
width: Number(options.width || 1920),
|
|
406
|
+
height: Number(options.height || 1080)
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
};
|
|
410
|
+
const context = await browser.newContext(contextOptions);
|
|
411
|
+
await context.addCookies([buildCookie(state)]);
|
|
412
|
+
return { browser, context, outDir };
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
export async function recordHopView(state, options = {}) {
|
|
416
|
+
const { browser, context, outDir } = await openRecordedContext(state, options);
|
|
417
|
+
const page = await context.newPage();
|
|
418
|
+
const targetPath = options.session
|
|
419
|
+
? `/s/${encodeURIComponent(options.session)}`
|
|
420
|
+
: "/sessions";
|
|
421
|
+
await page.goto(`${state.localUrl}${targetPath}`, {
|
|
422
|
+
waitUntil: "networkidle",
|
|
423
|
+
timeout: Number(options.timeoutMs || 30000)
|
|
424
|
+
});
|
|
425
|
+
if (options.sessionPrefix || options.allowedSessions) {
|
|
426
|
+
const allowedSessions = Array.isArray(options.allowedSessions)
|
|
427
|
+
? options.allowedSessions
|
|
428
|
+
: null;
|
|
429
|
+
const sessionPrefix = options.sessionPrefix || null;
|
|
430
|
+
await page.evaluate(({ prefix, allowed }) => {
|
|
431
|
+
const allowName = (name) => {
|
|
432
|
+
if (!name) return false;
|
|
433
|
+
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
434
|
+
return allowed.includes(name);
|
|
435
|
+
}
|
|
436
|
+
if (prefix) {
|
|
437
|
+
return name.startsWith(prefix);
|
|
438
|
+
}
|
|
439
|
+
return true;
|
|
440
|
+
};
|
|
441
|
+
|
|
442
|
+
document.querySelectorAll(".session-item-wrapper, .session-list-item").forEach((node) => {
|
|
443
|
+
const label = node.querySelector(".session-title, .session-name, .session-list-name");
|
|
444
|
+
const text = (label?.textContent || node.textContent || "").trim();
|
|
445
|
+
if (!allowName(text)) {
|
|
446
|
+
node.remove();
|
|
447
|
+
}
|
|
448
|
+
});
|
|
449
|
+
}, {
|
|
450
|
+
prefix: sessionPrefix,
|
|
451
|
+
allowed: allowedSessions
|
|
452
|
+
});
|
|
453
|
+
}
|
|
454
|
+
if (options.pageMode === "session-picker") {
|
|
455
|
+
await page.addStyleTag({
|
|
456
|
+
content: `
|
|
457
|
+
body { background: linear-gradient(180deg, #f6f1e8 0%, #f9f7f2 100%) !important; }
|
|
458
|
+
.container {
|
|
459
|
+
max-width: 1080px !important;
|
|
460
|
+
margin: 32px auto 0 !important;
|
|
461
|
+
transform: scale(1.08);
|
|
462
|
+
transform-origin: top center;
|
|
463
|
+
}
|
|
464
|
+
`
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
const settleMs = Number(options.settleMs || 1000);
|
|
468
|
+
const durationMs = Number(options.durationMs || 5000);
|
|
469
|
+
if (settleMs > 0) {
|
|
470
|
+
await sleep(settleMs);
|
|
471
|
+
}
|
|
472
|
+
const screenshotPath = path.join(outDir, options.screenshot || "capture.png");
|
|
473
|
+
await page.screenshot({ path: screenshotPath, fullPage: true });
|
|
474
|
+
if (durationMs > 0) {
|
|
475
|
+
await sleep(durationMs);
|
|
476
|
+
}
|
|
477
|
+
await context.close();
|
|
478
|
+
await browser.close();
|
|
479
|
+
return { outDir, screenshotPath };
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
export async function findFreePort() {
|
|
483
|
+
return new Promise((resolve, reject) => {
|
|
484
|
+
const server = net.createServer();
|
|
485
|
+
server.listen(0, "127.0.0.1", () => {
|
|
486
|
+
const address = server.address();
|
|
487
|
+
server.close(() => {
|
|
488
|
+
if (!address || typeof address !== "object") {
|
|
489
|
+
reject(new Error("Failed to allocate a local port"));
|
|
490
|
+
return;
|
|
491
|
+
}
|
|
492
|
+
resolve(address.port);
|
|
493
|
+
});
|
|
494
|
+
});
|
|
495
|
+
server.on("error", reject);
|
|
496
|
+
});
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
export async function waitForHttp(url, timeoutMs = 10000) {
|
|
500
|
+
const startedAt = Date.now();
|
|
501
|
+
while (true) {
|
|
502
|
+
try {
|
|
503
|
+
const response = await fetch(url);
|
|
504
|
+
if (response.ok) {
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
} catch {
|
|
508
|
+
// keep polling
|
|
509
|
+
}
|
|
510
|
+
if (Date.now() - startedAt > timeoutMs) {
|
|
511
|
+
throw new Error(`Timed out waiting for ${url}`);
|
|
512
|
+
}
|
|
513
|
+
await sleep(250);
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
export async function startStaticPreviewServer(rootDir, port) {
|
|
518
|
+
const child = spawn("/opt/homebrew/bin/node", [path.join(REPO_ROOT, "demo", "static-preview-server.mjs"), "--root", rootDir, "--port", String(port)], {
|
|
519
|
+
cwd: REPO_ROOT,
|
|
520
|
+
env: withNodePath(),
|
|
521
|
+
stdio: "pipe"
|
|
522
|
+
});
|
|
523
|
+
await waitForHttp(`http://127.0.0.1:${port}`, 10000);
|
|
524
|
+
return child;
|
|
525
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { getHopState, parseArgs, recordHopView } from "./hop-demo-lib.mjs";
|
|
2
|
+
|
|
3
|
+
const args = parseArgs(process.argv.slice(2));
|
|
4
|
+
const state = getHopState(args["hop-home"]);
|
|
5
|
+
|
|
6
|
+
const result = await recordHopView(state, {
|
|
7
|
+
session: args.session,
|
|
8
|
+
outDir: args.out || args["out-dir"],
|
|
9
|
+
mobile: args.mobile === true || args.mobile === "true",
|
|
10
|
+
width: args.width,
|
|
11
|
+
height: args.height,
|
|
12
|
+
headless: args.headless === "false" ? false : true,
|
|
13
|
+
timeoutMs: args.timeout,
|
|
14
|
+
settleMs: args.settle,
|
|
15
|
+
durationMs: args.duration,
|
|
16
|
+
screenshot: args.screenshot
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
console.log(JSON.stringify({
|
|
20
|
+
ok: true,
|
|
21
|
+
localUrl: state.localUrl,
|
|
22
|
+
publicUrl: state.publicUrl,
|
|
23
|
+
session: args.session || null,
|
|
24
|
+
outDir: result.outDir,
|
|
25
|
+
screenshot: result.screenshotPath
|
|
26
|
+
}, null, 2));
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import http from "node:http";
|
|
4
|
+
import { parseArgs } from "./hop-demo-lib.mjs";
|
|
5
|
+
|
|
6
|
+
const args = parseArgs(process.argv.slice(2));
|
|
7
|
+
const rootDir = path.resolve(args.root || process.cwd());
|
|
8
|
+
const port = Number(args.port || 43110);
|
|
9
|
+
|
|
10
|
+
const mimeTypes = {
|
|
11
|
+
".html": "text/html; charset=utf-8",
|
|
12
|
+
".css": "text/css; charset=utf-8",
|
|
13
|
+
".js": "application/javascript; charset=utf-8",
|
|
14
|
+
".json": "application/json; charset=utf-8",
|
|
15
|
+
".svg": "image/svg+xml",
|
|
16
|
+
".png": "image/png",
|
|
17
|
+
".jpg": "image/jpeg",
|
|
18
|
+
".jpeg": "image/jpeg",
|
|
19
|
+
".webp": "image/webp"
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const server = http.createServer((req, res) => {
|
|
23
|
+
const requestPath = req.url === "/" ? "/index.html" : String(req.url || "/index.html").split("?")[0];
|
|
24
|
+
const fullPath = path.resolve(path.join(rootDir, `.${requestPath}`));
|
|
25
|
+
if (!fullPath.startsWith(rootDir)) {
|
|
26
|
+
res.writeHead(403);
|
|
27
|
+
res.end("Forbidden");
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
if (!fs.existsSync(fullPath) || fs.statSync(fullPath).isDirectory()) {
|
|
31
|
+
res.writeHead(404);
|
|
32
|
+
res.end("Not found");
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
const ext = path.extname(fullPath).toLowerCase();
|
|
36
|
+
res.writeHead(200, {
|
|
37
|
+
"Content-Type": mimeTypes[ext] || "application/octet-stream",
|
|
38
|
+
"Cache-Control": "no-store"
|
|
39
|
+
});
|
|
40
|
+
fs.createReadStream(fullPath).pipe(res);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
server.listen(port, "127.0.0.1", () => {
|
|
44
|
+
process.stdout.write(`listening ${port}\n`);
|
|
45
|
+
});
|