@melaya/runner 1.0.18 → 1.0.20
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/dist/connection.js +35 -96
- package/dist/localRelay.d.ts +1 -1
- package/dist/localRelay.js +50 -1
- package/dist/lumaLogin.d.ts +43 -0
- package/dist/lumaLogin.js +190 -0
- package/package.json +1 -1
package/dist/connection.js
CHANGED
|
@@ -42,7 +42,7 @@ export async function connect(opts) {
|
|
|
42
42
|
socket.emit("runner:models", opts.models);
|
|
43
43
|
// Start local event relay
|
|
44
44
|
if (!relay) {
|
|
45
|
-
relay = await startLocalRelay(socket, opts.verbose);
|
|
45
|
+
relay = await startLocalRelay(socket, opts.verbose, opts.serverUrl);
|
|
46
46
|
if (opts.verbose) {
|
|
47
47
|
console.log(chalk.gray(` [relay] Listening on 127.0.0.1:${relay.port} (nonce: ${relay.nonce})`));
|
|
48
48
|
}
|
|
@@ -188,6 +188,34 @@ export async function connect(opts) {
|
|
|
188
188
|
});
|
|
189
189
|
}
|
|
190
190
|
});
|
|
191
|
+
// ── Luma cookie capture (one-time) ────────────────────────────────
|
|
192
|
+
// Same shape as the LinkedIn flow above. Cookies persist envelope-encrypted
|
|
193
|
+
// in agents.credentials → flow into pipeline subprocess env as
|
|
194
|
+
// LUMA_AUTH_SESSION_KEY / LUMA_USER_ID → consumed by shared/tools/luma.py.
|
|
195
|
+
socket.on("luma:start-login", async (req) => {
|
|
196
|
+
const sid = req?.session_id;
|
|
197
|
+
if (!sid)
|
|
198
|
+
return;
|
|
199
|
+
console.log(chalk.hex("#FFD43B")(`\n ▶ Luma login requested (session ${sid.slice(0, 8)}…)`));
|
|
200
|
+
const emitProgress = (msg) => {
|
|
201
|
+
socket.emit("luma:login-progress", { session_id: sid, message: msg });
|
|
202
|
+
if (opts.verbose)
|
|
203
|
+
console.log(chalk.gray(` [luma] ${msg}`));
|
|
204
|
+
};
|
|
205
|
+
try {
|
|
206
|
+
const { runLumaLoginFlow, describeOutcome } = await import("./lumaLogin.js");
|
|
207
|
+
const outcome = await runLumaLoginFlow(emitProgress);
|
|
208
|
+
console.log(describeOutcome(outcome));
|
|
209
|
+
socket.emit("luma:login-result", { session_id: sid, ...outcome });
|
|
210
|
+
}
|
|
211
|
+
catch (e) {
|
|
212
|
+
const msg = e?.message || "luma_login_unhandled_error";
|
|
213
|
+
console.log(chalk.red(` ✗ Luma login failed: ${msg}`));
|
|
214
|
+
socket.emit("luma:login-result", {
|
|
215
|
+
session_id: sid, ok: false, error: "login_unhandled_error", detail: msg,
|
|
216
|
+
});
|
|
217
|
+
}
|
|
218
|
+
});
|
|
191
219
|
// ── Kill command ───────────────────────────────────────────────────
|
|
192
220
|
socket.on("runner:kill", (data) => {
|
|
193
221
|
const proc = activeProcesses.get(data.runId);
|
|
@@ -197,102 +225,13 @@ export async function connect(opts) {
|
|
|
197
225
|
console.log(chalk.yellow(` ■ Killed run ${data.runId.slice(0, 10)}...`));
|
|
198
226
|
}
|
|
199
227
|
});
|
|
200
|
-
// ──
|
|
201
|
-
//
|
|
202
|
-
// The cloud server picked the user's local Ollama / LM Studio model
|
|
203
|
-
// for an AI feature (Build-with-AI architect, etc.) — but the LLM
|
|
204
|
-
// server only exists on this machine, so we proxy the chat-completion
|
|
205
|
-
// here: fetch the local URL, stream chunks back over the existing
|
|
206
|
-
// socket. The server-side `streamLLMViaRunner()` consumes them.
|
|
228
|
+
// ── AI build-pipeline LLM-relay ────────────────────────────────────
|
|
207
229
|
//
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
212
|
-
//
|
|
213
|
-
// server → runner: runner:llm-stream-cancel { reqId }
|
|
214
|
-
const _activeLLMStreams = new Map();
|
|
215
|
-
socket.on("runner:llm-stream-request", async (req) => {
|
|
216
|
-
const reqId = req?.reqId;
|
|
217
|
-
if (!reqId || typeof req.url !== "string")
|
|
218
|
-
return;
|
|
219
|
-
// Defense-in-depth: only allow loopback URLs. The server already
|
|
220
|
-
// generates these as `http://localhost:1234` / `http://localhost:11434`,
|
|
221
|
-
// but enforcing it here means a compromised server connection can't
|
|
222
|
-
// pivot the runner into making outbound requests on its behalf.
|
|
223
|
-
let parsed;
|
|
224
|
-
try {
|
|
225
|
-
parsed = new URL(req.url);
|
|
226
|
-
}
|
|
227
|
-
catch {
|
|
228
|
-
socket.emit("runner:llm-stream-error", { reqId, error: "invalid_url" });
|
|
229
|
-
return;
|
|
230
|
-
}
|
|
231
|
-
if (parsed.hostname !== "localhost" && parsed.hostname !== "127.0.0.1" && parsed.hostname !== "::1") {
|
|
232
|
-
socket.emit("runner:llm-stream-error", { reqId, error: "non_loopback_url_rejected" });
|
|
233
|
-
return;
|
|
234
|
-
}
|
|
235
|
-
if (opts.verbose)
|
|
236
|
-
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} → ${req.url}`));
|
|
237
|
-
const ac = new AbortController();
|
|
238
|
-
_activeLLMStreams.set(reqId, ac);
|
|
239
|
-
try {
|
|
240
|
-
const upstream = await fetch(req.url, {
|
|
241
|
-
method: "POST",
|
|
242
|
-
headers: req.headers || { "content-type": "application/json" },
|
|
243
|
-
body: req.body || "",
|
|
244
|
-
signal: ac.signal,
|
|
245
|
-
});
|
|
246
|
-
if (!upstream.body) {
|
|
247
|
-
const text = await upstream.text().catch(() => "");
|
|
248
|
-
socket.emit("runner:llm-stream-error", {
|
|
249
|
-
reqId,
|
|
250
|
-
error: `upstream_no_body status=${upstream.status} ${text.slice(0, 200)}`,
|
|
251
|
-
});
|
|
252
|
-
return;
|
|
253
|
-
}
|
|
254
|
-
// Pipe chunks back as decoded text. The cloud server's parser is
|
|
255
|
-
// text-oriented (SSE `data:` lines) so we decode here and ship
|
|
256
|
-
// strings rather than base64 — keeps the WS payload small + lets
|
|
257
|
-
// the server keep its existing line-buffer code path.
|
|
258
|
-
const reader = upstream.body.getReader();
|
|
259
|
-
const decoder = new TextDecoder();
|
|
260
|
-
while (true) {
|
|
261
|
-
const { value, done } = await reader.read();
|
|
262
|
-
if (done)
|
|
263
|
-
break;
|
|
264
|
-
const chunk = decoder.decode(value, { stream: true });
|
|
265
|
-
if (chunk.length > 0) {
|
|
266
|
-
socket.emit("runner:llm-stream-chunk", { reqId, chunk });
|
|
267
|
-
}
|
|
268
|
-
}
|
|
269
|
-
// Flush any tail bytes.
|
|
270
|
-
const tail = decoder.decode();
|
|
271
|
-
if (tail.length > 0)
|
|
272
|
-
socket.emit("runner:llm-stream-chunk", { reqId, chunk: tail });
|
|
273
|
-
socket.emit("runner:llm-stream-end", { reqId, status: upstream.status });
|
|
274
|
-
if (opts.verbose)
|
|
275
|
-
console.log(chalk.gray(` [llm-relay] ${reqId.slice(0, 12)} ◄ end ${upstream.status}`));
|
|
276
|
-
}
|
|
277
|
-
catch (e) {
|
|
278
|
-
if (e?.name === "AbortError") {
|
|
279
|
-
socket.emit("runner:llm-stream-error", { reqId, error: "aborted" });
|
|
280
|
-
}
|
|
281
|
-
else {
|
|
282
|
-
socket.emit("runner:llm-stream-error", { reqId, error: String(e?.message || e) });
|
|
283
|
-
}
|
|
284
|
-
}
|
|
285
|
-
finally {
|
|
286
|
-
_activeLLMStreams.delete(reqId);
|
|
287
|
-
}
|
|
288
|
-
});
|
|
289
|
-
socket.on("runner:llm-stream-cancel", (req) => {
|
|
290
|
-
const ac = _activeLLMStreams.get(req?.reqId);
|
|
291
|
-
if (ac) {
|
|
292
|
-
ac.abort();
|
|
293
|
-
_activeLLMStreams.delete(req.reqId);
|
|
294
|
-
}
|
|
295
|
-
});
|
|
230
|
+
// No new socket events — the build-pipeline relay rides on the
|
|
231
|
+
// EXISTING `runner:run` event. builder_server packages a thin Python
|
|
232
|
+
// script that fetches `localhost:1234`/`11434` and POSTs each SSE
|
|
233
|
+
// line back as an HTTP callback to the cloud server. Same pattern
|
|
234
|
+
// as the BacktestOptimizer dispatch.
|
|
296
235
|
// ── Graceful shutdown ──────────────────────────────────────────────
|
|
297
236
|
const cleanup = () => {
|
|
298
237
|
console.log(chalk.gray("\n Shutting down..."));
|
package/dist/localRelay.d.ts
CHANGED
|
@@ -16,4 +16,4 @@ export interface LocalRelay {
|
|
|
16
16
|
close: () => void;
|
|
17
17
|
}
|
|
18
18
|
export declare function setActiveProject(project: string): void;
|
|
19
|
-
export declare function startLocalRelay(socket: Socket, verbose: boolean): Promise<LocalRelay>;
|
|
19
|
+
export declare function startLocalRelay(socket: Socket, verbose: boolean, serverUrl?: string): Promise<LocalRelay>;
|
package/dist/localRelay.js
CHANGED
|
@@ -55,11 +55,56 @@ function _flushThrottle(socket, verbose) {
|
|
|
55
55
|
_throttleQueue = [];
|
|
56
56
|
_throttleTimer = null;
|
|
57
57
|
}
|
|
58
|
-
export function startLocalRelay(socket, verbose) {
|
|
58
|
+
export function startLocalRelay(socket, verbose, serverUrl) {
|
|
59
59
|
const nonce = crypto.randomUUID().replace(/-/g, "");
|
|
60
60
|
return new Promise((resolve, reject) => {
|
|
61
61
|
const expectedPath = `/events/relay/${nonce}`;
|
|
62
|
+
// Strip trailing slash and convert ws:// → http:// for the upstream
|
|
63
|
+
// poll proxy below. The cloud Node API is the source of truth for
|
|
64
|
+
// approval decisions; the runner just forwards.
|
|
65
|
+
const upstream = (serverUrl || "https://api.melaya.org")
|
|
66
|
+
.replace(/^wss:\/\//, "https://")
|
|
67
|
+
.replace(/^ws:\/\//, "http://")
|
|
68
|
+
.replace(/\/+$/, "");
|
|
62
69
|
const server = http.createServer((req, res) => {
|
|
70
|
+
// ── HITL approval-poll proxy ────────────────────────────────────
|
|
71
|
+
// The python middleware on the agent subprocess polls
|
|
72
|
+
// `GET ${MEL_BUILDER_URL}/pipelines/<name>/approvals/<request_id>`
|
|
73
|
+
// to learn when the operator approved a tool call. MEL_BUILDER_URL
|
|
74
|
+
// is wired to this local relay (so per-token POSTs land here). We
|
|
75
|
+
// forward the GET to the cloud Node API's `hitl.pollApproval` tRPC
|
|
76
|
+
// endpoint (event-secret-gated upstream — we attach the secret
|
|
77
|
+
// server-side). Without this proxy the GET 404s here, the python
|
|
78
|
+
// poll loop never breaks, the run stays stuck after approval, and
|
|
79
|
+
// the operator sees "approved but nothing happened" — exactly the
|
|
80
|
+
// bug observed on run 1fd397d67db24b9a (May 2026).
|
|
81
|
+
const m = req.method === "GET" && req.url
|
|
82
|
+
? req.url.match(/^\/pipelines\/[^/]+\/approvals\/([A-Za-z0-9_-]{1,200})\/?$/)
|
|
83
|
+
: null;
|
|
84
|
+
if (m) {
|
|
85
|
+
const requestId = m[1];
|
|
86
|
+
// Forward via socket.io rather than direct HTTP — the runner is
|
|
87
|
+
// already authenticated to the cloud server, so we use it as
|
|
88
|
+
// the trust anchor. Server-side this is `runner:pollApproval`,
|
|
89
|
+
// which calls _validateEventSecret-equivalent before the DB read.
|
|
90
|
+
const replyOnce = (status, body) => {
|
|
91
|
+
if (res.headersSent)
|
|
92
|
+
return;
|
|
93
|
+
res.writeHead(status, { "Content-Type": "application/json" });
|
|
94
|
+
res.end(JSON.stringify(body));
|
|
95
|
+
};
|
|
96
|
+
const t = setTimeout(() => replyOnce(504, { status: "pending", error: "upstream_timeout" }), 6000);
|
|
97
|
+
socket.emit("runner:pollApproval", { requestId }, (resp) => {
|
|
98
|
+
clearTimeout(t);
|
|
99
|
+
if (resp && typeof resp === "object") {
|
|
100
|
+
replyOnce(200, resp);
|
|
101
|
+
}
|
|
102
|
+
else {
|
|
103
|
+
replyOnce(502, { status: "pending", error: "no_response" });
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
63
108
|
if (req.method !== "POST" || req.url !== expectedPath) {
|
|
64
109
|
res.writeHead(404);
|
|
65
110
|
res.end("Not found");
|
|
@@ -94,6 +139,10 @@ export function startLocalRelay(socket, verbose) {
|
|
|
94
139
|
res.end("Invalid JSON");
|
|
95
140
|
}
|
|
96
141
|
});
|
|
142
|
+
// Suppress: `serverUrl` only consumed once at relay start to build
|
|
143
|
+
// the upstream URL — keep the variable referenced so TS doesn't
|
|
144
|
+
// flag it as unused if the GET proxy is removed in the future.
|
|
145
|
+
void upstream;
|
|
97
146
|
});
|
|
98
147
|
server.listen(0, "127.0.0.1", () => {
|
|
99
148
|
const addr = server.address();
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luma cookie capture via a controlled Chromium window.
|
|
3
|
+
*
|
|
4
|
+
* Architecture mirrors `linkedinLogin.ts` — Luma auth is cookie-only,
|
|
5
|
+
* not OAuth, and the cookies can only be obtained by completing a real
|
|
6
|
+
* sign-in (Google OAuth handoff, passkey challenge, or magic-link email)
|
|
7
|
+
* through Luma's UI. Once captured, every subsequent Luma API call is
|
|
8
|
+
* direct aiohttp HTTPS via `melayaAgents/shared/tools/luma.py` — no
|
|
9
|
+
* browser needed for ongoing pipeline work.
|
|
10
|
+
*
|
|
11
|
+
* Flow
|
|
12
|
+
* ----
|
|
13
|
+
* 1. The Melaya server emits `luma:start-login` to this runner.
|
|
14
|
+
* 2. We launch a HEADED Chromium window pointed at https://luma.com/signin.
|
|
15
|
+
* 3. Poll the page URL — Luma redirects to /home (or /events/manage) once
|
|
16
|
+
* the user is authenticated.
|
|
17
|
+
* 4. Extract `luma.auth-session-key` and `luma.user-id` cookies and
|
|
18
|
+
* return them via `luma:login-result`.
|
|
19
|
+
*
|
|
20
|
+
* The chromium-install bootstrap is shared with the LinkedIn flow — if
|
|
21
|
+
* the user has already done a LinkedIn sign-in once, the second connector
|
|
22
|
+
* runs in <2s.
|
|
23
|
+
*/
|
|
24
|
+
export interface LumaLoginResult {
|
|
25
|
+
ok: true;
|
|
26
|
+
cookies: {
|
|
27
|
+
auth_session_key: string;
|
|
28
|
+
user_id?: string;
|
|
29
|
+
};
|
|
30
|
+
profileName?: string;
|
|
31
|
+
}
|
|
32
|
+
export interface LumaLoginError {
|
|
33
|
+
ok: false;
|
|
34
|
+
error: string;
|
|
35
|
+
detail?: string;
|
|
36
|
+
}
|
|
37
|
+
export type LumaLoginOutcome = LumaLoginResult | LumaLoginError;
|
|
38
|
+
interface ProgressEmitter {
|
|
39
|
+
(msg: string): void;
|
|
40
|
+
}
|
|
41
|
+
export declare function runLumaLoginFlow(progress: ProgressEmitter): Promise<LumaLoginOutcome>;
|
|
42
|
+
export declare function describeOutcome(outcome: LumaLoginOutcome): string;
|
|
43
|
+
export {};
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Luma cookie capture via a controlled Chromium window.
|
|
3
|
+
*
|
|
4
|
+
* Architecture mirrors `linkedinLogin.ts` — Luma auth is cookie-only,
|
|
5
|
+
* not OAuth, and the cookies can only be obtained by completing a real
|
|
6
|
+
* sign-in (Google OAuth handoff, passkey challenge, or magic-link email)
|
|
7
|
+
* through Luma's UI. Once captured, every subsequent Luma API call is
|
|
8
|
+
* direct aiohttp HTTPS via `melayaAgents/shared/tools/luma.py` — no
|
|
9
|
+
* browser needed for ongoing pipeline work.
|
|
10
|
+
*
|
|
11
|
+
* Flow
|
|
12
|
+
* ----
|
|
13
|
+
* 1. The Melaya server emits `luma:start-login` to this runner.
|
|
14
|
+
* 2. We launch a HEADED Chromium window pointed at https://luma.com/signin.
|
|
15
|
+
* 3. Poll the page URL — Luma redirects to /home (or /events/manage) once
|
|
16
|
+
* the user is authenticated.
|
|
17
|
+
* 4. Extract `luma.auth-session-key` and `luma.user-id` cookies and
|
|
18
|
+
* return them via `luma:login-result`.
|
|
19
|
+
*
|
|
20
|
+
* The chromium-install bootstrap is shared with the LinkedIn flow — if
|
|
21
|
+
* the user has already done a LinkedIn sign-in once, the second connector
|
|
22
|
+
* runs in <2s.
|
|
23
|
+
*/
|
|
24
|
+
import { spawn } from "child_process";
|
|
25
|
+
import chalk from "chalk";
|
|
26
|
+
const LOGIN_TIMEOUT_MS = 5 * 60_000;
|
|
27
|
+
const POLL_INTERVAL_MS = 750;
|
|
28
|
+
// Pages that mean "logged in to Luma". Luma's web app routes the user to
|
|
29
|
+
// /home after sign-in; some flows (especially Google OAuth) bounce through
|
|
30
|
+
// /events or /home depending on the entry point.
|
|
31
|
+
const LOGGED_IN_PATHS = [
|
|
32
|
+
"/home",
|
|
33
|
+
"/manage",
|
|
34
|
+
"/events/manage",
|
|
35
|
+
"/calendar/",
|
|
36
|
+
"/u/",
|
|
37
|
+
"/settings",
|
|
38
|
+
];
|
|
39
|
+
async function _ensurePlaywrightAvailable(progress) {
|
|
40
|
+
try {
|
|
41
|
+
return await import("playwright");
|
|
42
|
+
}
|
|
43
|
+
catch (e) {
|
|
44
|
+
progress("Playwright module not found in node_modules. Re-installing the runner usually fixes this.");
|
|
45
|
+
throw new Error(`playwright_unavailable: ${e?.message || e}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
function _installChromium(progress) {
|
|
49
|
+
return new Promise((resolve, reject) => {
|
|
50
|
+
progress("Preparing browser for Luma sign-in (one-time setup, ~140 MB)…");
|
|
51
|
+
const child = spawn("npx", ["playwright", "install", "chromium"], {
|
|
52
|
+
shell: true,
|
|
53
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
54
|
+
});
|
|
55
|
+
child.stdout?.on("data", (b) => {
|
|
56
|
+
const line = b.toString().trim();
|
|
57
|
+
if (line)
|
|
58
|
+
progress(line);
|
|
59
|
+
});
|
|
60
|
+
child.stderr?.on("data", (b) => {
|
|
61
|
+
const line = b.toString().trim();
|
|
62
|
+
if (line)
|
|
63
|
+
progress(line);
|
|
64
|
+
});
|
|
65
|
+
child.on("error", (e) => reject(new Error(`chromium_install_error: ${e.message}`)));
|
|
66
|
+
child.on("exit", (code) => {
|
|
67
|
+
if (code === 0) {
|
|
68
|
+
progress("Browser ready.");
|
|
69
|
+
resolve();
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
reject(new Error(`chromium_install_failed: exit code ${code}`));
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
function _pageLooksLoggedIn(url) {
|
|
78
|
+
try {
|
|
79
|
+
const u = new URL(url);
|
|
80
|
+
if (u.hostname !== "luma.com" && u.hostname !== "lu.ma" && u.hostname !== "www.luma.com")
|
|
81
|
+
return false;
|
|
82
|
+
return LOGGED_IN_PATHS.some((p) => u.pathname.startsWith(p));
|
|
83
|
+
}
|
|
84
|
+
catch {
|
|
85
|
+
return false;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
export async function runLumaLoginFlow(progress) {
|
|
89
|
+
let playwright;
|
|
90
|
+
try {
|
|
91
|
+
playwright = await _ensurePlaywrightAvailable(progress);
|
|
92
|
+
}
|
|
93
|
+
catch (e) {
|
|
94
|
+
return { ok: false, error: "playwright_unavailable", detail: e?.message };
|
|
95
|
+
}
|
|
96
|
+
try {
|
|
97
|
+
await _installChromium(progress);
|
|
98
|
+
}
|
|
99
|
+
catch (e) {
|
|
100
|
+
return { ok: false, error: "chromium_install_failed", detail: e?.message };
|
|
101
|
+
}
|
|
102
|
+
let browser = null;
|
|
103
|
+
try {
|
|
104
|
+
progress("Opening Luma login window…");
|
|
105
|
+
browser = await playwright.chromium.launch({
|
|
106
|
+
headless: false,
|
|
107
|
+
args: ["--disable-blink-features=AutomationControlled"],
|
|
108
|
+
});
|
|
109
|
+
const context = await browser.newContext({
|
|
110
|
+
userAgent: "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 " +
|
|
111
|
+
"(KHTML, like Gecko) Chrome/147.0.0.0 Safari/537.36",
|
|
112
|
+
viewport: { width: 1280, height: 820 },
|
|
113
|
+
});
|
|
114
|
+
const page = await context.newPage();
|
|
115
|
+
await page.goto("https://luma.com/signin", { waitUntil: "domcontentloaded" });
|
|
116
|
+
progress("Sign in with your Luma account in the window that just opened (Google / passkey / magic-link email all work).");
|
|
117
|
+
const start = Date.now();
|
|
118
|
+
let succeeded = false;
|
|
119
|
+
while (Date.now() - start < LOGIN_TIMEOUT_MS) {
|
|
120
|
+
let url;
|
|
121
|
+
try {
|
|
122
|
+
url = page.url();
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return { ok: false, error: "user_cancelled", detail: "Login window was closed before sign-in completed." };
|
|
126
|
+
}
|
|
127
|
+
if (_pageLooksLoggedIn(url)) {
|
|
128
|
+
succeeded = true;
|
|
129
|
+
break;
|
|
130
|
+
}
|
|
131
|
+
await new Promise((r) => setTimeout(r, POLL_INTERVAL_MS));
|
|
132
|
+
}
|
|
133
|
+
if (!succeeded) {
|
|
134
|
+
return { ok: false, error: "login_timeout", detail: "5-minute sign-in window expired." };
|
|
135
|
+
}
|
|
136
|
+
// Cookie names captured 2026-05-01 from luma.com after signin:
|
|
137
|
+
// luma.auth-session-key — required for every api2.luma.com call
|
|
138
|
+
// luma.user-id — informational, lets tools short-circuit /me
|
|
139
|
+
// Read both luma.com and api2.luma.com cookies — the session is shared
|
|
140
|
+
// across both subdomains via the registrable parent.
|
|
141
|
+
const allCookies = await context.cookies();
|
|
142
|
+
const auth_c = allCookies.find((c) => c.name === "luma.auth-session-key");
|
|
143
|
+
const uid_c = allCookies.find((c) => c.name === "luma.user-id");
|
|
144
|
+
if (!auth_c?.value) {
|
|
145
|
+
return {
|
|
146
|
+
ok: false, error: "cookies_not_found",
|
|
147
|
+
detail: "Sign-in completed but luma.auth-session-key cookie was missing. Try again and ensure the sign-in fully redirects to luma.com/home.",
|
|
148
|
+
};
|
|
149
|
+
}
|
|
150
|
+
let profileName;
|
|
151
|
+
try {
|
|
152
|
+
profileName = await page.evaluate(() => {
|
|
153
|
+
const sel = (s) => {
|
|
154
|
+
const el = document.querySelector(s);
|
|
155
|
+
return el ? (el.textContent || "").trim() : "";
|
|
156
|
+
};
|
|
157
|
+
return (sel('[data-testid="user-menu-name"]')
|
|
158
|
+
|| sel('header [class*="UserMenu"] [class*="name"]')
|
|
159
|
+
|| sel('a[href*="/u/"] [class*="name"]')
|
|
160
|
+
|| undefined);
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
catch { /* swallow */ }
|
|
164
|
+
progress(profileName ? `Captured session for ${profileName}.` : "Captured session.");
|
|
165
|
+
await context.close();
|
|
166
|
+
await browser.close();
|
|
167
|
+
return {
|
|
168
|
+
ok: true,
|
|
169
|
+
cookies: { auth_session_key: auth_c.value, user_id: uid_c?.value },
|
|
170
|
+
profileName,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
catch (e) {
|
|
174
|
+
return { ok: false, error: "login_flow_error", detail: e?.message || String(e) };
|
|
175
|
+
}
|
|
176
|
+
finally {
|
|
177
|
+
if (browser) {
|
|
178
|
+
try {
|
|
179
|
+
await browser.close();
|
|
180
|
+
}
|
|
181
|
+
catch { /* already closed */ }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
export function describeOutcome(outcome) {
|
|
186
|
+
if (outcome.ok) {
|
|
187
|
+
return chalk.green(` ✓ Luma session captured${outcome.profileName ? ` (${outcome.profileName})` : ""}.`);
|
|
188
|
+
}
|
|
189
|
+
return chalk.red(` ✗ Luma login: ${outcome.error}${outcome.detail ? ` — ${outcome.detail}` : ""}`);
|
|
190
|
+
}
|