@ai-digital-factory/lmt-cli 0.1.6 → 1.0.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.
@@ -0,0 +1,2799 @@
1
+ import {
2
+ BROWSER_CLIENT_KIND,
3
+ CONTROLLER_LOGIN_STEP,
4
+ CONTROLLER_SESSION,
5
+ CliProfileStore,
6
+ DEFAULT_CONFIG_PATH,
7
+ LocalConfigError,
8
+ SmsReadError,
9
+ readOperatorLoginCredentials,
10
+ reportSessionStatus,
11
+ resolveCliTarget,
12
+ resolveTargetOrigin
13
+ } from "./chunk-4N3XUBM4.js";
14
+
15
+ // src/web/index.ts
16
+ import { spawn as spawn2 } from "node:child_process";
17
+ import { createRequire } from "node:module";
18
+ import path4 from "node:path";
19
+
20
+ // src/web/owners.ts
21
+ import { execFileSync } from "node:child_process";
22
+ import { randomUUID } from "node:crypto";
23
+ import { constants } from "node:fs";
24
+ import { lstat as lstat2, open, readFile, rename, unlink as unlink2 } from "node:fs/promises";
25
+ import path2 from "node:path";
26
+
27
+ // src/web/session.ts
28
+ import { randomBytes as randomBytes2 } from "node:crypto";
29
+ import { chmod, lstat, mkdir, unlink } from "node:fs/promises";
30
+ import net from "node:net";
31
+ import os from "node:os";
32
+ import path from "node:path";
33
+ import { chromium as chromium2 } from "playwright";
34
+
35
+ // ../../packages/crawler/src/full-content.ts
36
+ async function captureFullContent(browserPage, options) {
37
+ const page = browserPage;
38
+ if (!page.evaluate) throw new Error("Full-content capture is unavailable.");
39
+ try {
40
+ await page.evaluate(() => {
41
+ const state = [];
42
+ window.__lmtCapture = state;
43
+ const scrolling = Array.from(document.querySelectorAll("body *")).filter((element) => {
44
+ const style = getComputedStyle(element);
45
+ if (!/(auto|scroll|hidden|clip)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight) {
46
+ return false;
47
+ }
48
+ if (/(auto|scroll)/.test(style.overflowY)) return true;
49
+ return element.closest('.op-page[data-operator-page="true"]') !== null;
50
+ });
51
+ const expand = /* @__PURE__ */ new Set();
52
+ for (const element of scrolling) {
53
+ for (let parent = element; parent; parent = parent.parentElement) expand.add(parent);
54
+ }
55
+ for (const element of expand) {
56
+ state.push({ element, style: element.getAttribute("style"), x: element.scrollLeft, y: element.scrollTop });
57
+ element.style.setProperty("height", "auto", "important");
58
+ element.style.setProperty("max-height", "none", "important");
59
+ element.style.setProperty("overflow", "visible", "important");
60
+ element.style.setProperty("flex-shrink", "0", "important");
61
+ element.scrollTop = 0;
62
+ }
63
+ });
64
+ await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
65
+ const complete = await page.evaluate(() => Array.from(document.querySelectorAll("body *")).every((element) => {
66
+ const style = getComputedStyle(element);
67
+ const assistiveText = element.getAttribute("data-lmt-capture") === "assistive-text" && style.position === "absolute" && element.clientWidth <= 1 && element.clientHeight <= 1;
68
+ return assistiveText || style.display === "none" || element.clientHeight === 0 || !/(auto|scroll|hidden|clip)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight + 1;
69
+ }));
70
+ if (!complete) throw new Error("Full-content capture could not expand all clipped content.");
71
+ return await page.screenshot({ ...options, fullPage: true });
72
+ } finally {
73
+ await page.evaluate(() => {
74
+ const owner = window;
75
+ for (const { element, style, x, y } of (owner.__lmtCapture ?? []).reverse()) {
76
+ if (style === null) element.removeAttribute("style");
77
+ else element.setAttribute("style", style);
78
+ element.scrollLeft = x;
79
+ element.scrollTop = y;
80
+ }
81
+ delete owner.__lmtCapture;
82
+ }).catch(() => void 0);
83
+ }
84
+ }
85
+
86
+ // ../../packages/crawler/src/controls.ts
87
+ import { createHash, randomBytes } from "node:crypto";
88
+ var ControlError = class extends Error {
89
+ constructor(code, message) {
90
+ super(message);
91
+ this.code = code;
92
+ }
93
+ code;
94
+ };
95
+ function sensitiveField(node) {
96
+ if (node.hasAttribute("data-lmt-sensitive")) return true;
97
+ if (node.getAttribute("data-lmt-readable") === "ordinary") return false;
98
+ const description = [
99
+ node.getAttribute("type"),
100
+ node.id,
101
+ node.getAttribute("name"),
102
+ node.getAttribute("aria-label"),
103
+ ...Array.from(node.labels ?? []).map((label) => label.textContent)
104
+ ].join(" ");
105
+ return /password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin/i.test(description);
106
+ }
107
+ function secretMetadata(value) {
108
+ return {
109
+ present: value.length > 0,
110
+ length: value.length,
111
+ fingerprint: value ? createHash("sha256").update(value).digest("hex").slice(0, 12) : null
112
+ };
113
+ }
114
+ function controlSignature(node) {
115
+ if (!node.isConnected) return null;
116
+ return JSON.stringify([
117
+ node.tagName,
118
+ node.getAttribute("type"),
119
+ node.getAttribute("name"),
120
+ node.getAttribute("aria-label"),
121
+ node.getAttribute("href"),
122
+ node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement ? "" : node.textContent
123
+ ]);
124
+ }
125
+ var PageControls = class {
126
+ constructor(page, origin) {
127
+ this.origin = origin;
128
+ this.page = page;
129
+ }
130
+ origin;
131
+ page;
132
+ references = /* @__PURE__ */ new Map();
133
+ secrets = /* @__PURE__ */ new Set();
134
+ prefix = randomBytes(8).toString("hex");
135
+ sequence = 0;
136
+ registerSecrets(...values) {
137
+ for (const value of values) if (value) this.secrets.add(value);
138
+ }
139
+ redact(value) {
140
+ let result = value;
141
+ for (const secret of [...this.secrets].sort((a, b) => b.length - a.length)) {
142
+ result = result.split(secret).join("[redacted]");
143
+ result = result.split(encodeURIComponent(secret)).join("[redacted]");
144
+ }
145
+ return result;
146
+ }
147
+ async learnSecrets() {
148
+ const fields = await this.page.$$("input, textarea, select, [data-lmt-sensitive]");
149
+ const values = [];
150
+ for (const field of fields) {
151
+ try {
152
+ if (await field.evaluate(sensitiveField)) values.push(await field.evaluate((node) => node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement ? node.value : node.textContent ?? ""));
153
+ } finally {
154
+ await field.dispose();
155
+ }
156
+ }
157
+ const textSecrets = await this.page.evaluate(() => Array.from((document.body?.innerText ?? "").matchAll(
158
+ /(?:access[\s_-]*token|request[\s_-]*token|api[\s_-]*(?:secret|key|token)|password|credential|otp)[\t ]*[:=][\t ]*([^\s,;]+)/gi
159
+ ), (match) => match[1]).filter(Boolean));
160
+ values.push(...textSecrets);
161
+ values.push(...await this.page.evaluate(() => window.__lmtPasteSecrets ?? []));
162
+ this.registerSecrets(...values);
163
+ }
164
+ async screenshotMasks() {
165
+ const stable = this.page.locator("[data-lmt-sensitive]");
166
+ await this.learnSecrets();
167
+ const fields = await this.page.$$("input, textarea, select");
168
+ for (const field of fields) {
169
+ try {
170
+ const value = await field.inputValue();
171
+ const sensitive = value.length > 0 && (await field.evaluate(sensitiveField) || [...this.secrets].some((secret) => value.includes(secret)));
172
+ await field.evaluate((node, mask) => node.toggleAttribute("data-lmt-field-mask", mask), sensitive);
173
+ } finally {
174
+ await field.dispose();
175
+ }
176
+ }
177
+ await this.page.evaluate((secrets) => {
178
+ document.querySelectorAll("[data-lmt-secret-mask]").forEach((element) => element.removeAttribute("data-lmt-secret-mask"));
179
+ if (!secrets.length) return;
180
+ for (const element of document.querySelectorAll("body *")) {
181
+ const directText = Array.from(element.childNodes).filter((node) => node.nodeType === Node.TEXT_NODE).map((node) => node.textContent).join("");
182
+ if (secrets.some((secret) => directText.includes(secret) || (element.textContent ?? "").includes(secret) && !Array.from(element.children).some((child) => (child.textContent ?? "").includes(secret)))) element.setAttribute("data-lmt-secret-mask", "");
183
+ }
184
+ }, [...this.secrets]);
185
+ return [stable, this.page.locator("[data-lmt-field-mask], [data-lmt-secret-mask]")];
186
+ }
187
+ async inspect() {
188
+ await this.learnSecrets();
189
+ for (const { element } of this.references.values()) await element.dispose().catch(() => void 0);
190
+ this.references.clear();
191
+ const controls = [];
192
+ const elements = await this.page.$$("button, a[href], input, select, textarea, [role=button], [contenteditable=true]");
193
+ for (const element of elements) {
194
+ if (controls.length >= 100 || !await element.isVisible()) {
195
+ await element.dispose();
196
+ continue;
197
+ }
198
+ const description = await element.evaluate((node) => {
199
+ const labels = Array.from(node.labels ?? []).map((label) => {
200
+ const copy = label.cloneNode(true);
201
+ copy.querySelectorAll("input,select,textarea,button").forEach((control) => control.remove());
202
+ return copy.textContent ?? "";
203
+ }).join(" ");
204
+ return {
205
+ kind: node.getAttribute("role") || node.tagName.toLowerCase(),
206
+ label: (node.getAttribute("aria-label") || node.getAttribute("title") || labels || node.getAttribute("placeholder") || (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement ? "" : node.textContent) || "").replace(/\s+/g, " ").trim()
207
+ };
208
+ });
209
+ const reference = `${this.prefix}-${++this.sequence}`;
210
+ this.references.set(reference, { element, signature: await element.evaluate(controlSignature) });
211
+ controls.push({ reference, kind: description.kind, label: this.redact(description.label).slice(0, 160) });
212
+ }
213
+ return controls;
214
+ }
215
+ async resolve(reference) {
216
+ const saved = this.references.get(reference);
217
+ const element = saved?.element;
218
+ if (!element || !saved?.signature || await element.evaluate(controlSignature).catch(() => null) !== saved.signature) {
219
+ throw new ControlError("stale_reference", "The control reference is missing or stale. Inspect the page for current references.");
220
+ }
221
+ if (!await element.isVisible()) throw new ControlError("stale_reference", "The referenced control is no longer visible. Inspect the page again.");
222
+ return element;
223
+ }
224
+ async stopLoading() {
225
+ const session = await this.page.context().newCDPSession(this.page);
226
+ try {
227
+ await session.send("Page.stopLoading");
228
+ } finally {
229
+ await session.detach();
230
+ }
231
+ }
232
+ async act(command, timeoutMs, onSubmit = () => void 0) {
233
+ this.page.setDefaultTimeout(timeoutMs);
234
+ this.page.setDefaultNavigationTimeout(timeoutMs);
235
+ if (command.action === "read" || command.action === "check" || command.action === "clipboard-check" || command.action === "clipboard") {
236
+ await this.learnSecrets();
237
+ if (command.action === "check") this.registerSecrets(command.expected);
238
+ try {
239
+ const field = command.action === "clipboard" ? void 0 : await this.resolve(command.reference);
240
+ if (field && await field.getAttribute("data-lmt-value-state") === "concealed") {
241
+ throw new ControlError("value_unreadable", "This field contains a display placeholder. Reveal the value or read the clipboard after Copy.");
242
+ }
243
+ if (command.action === "clipboard-check" || command.action === "clipboard") await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
244
+ if (command.action === "clipboard-check") {
245
+ const matches = await field.evaluate(async (node) => {
246
+ if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
247
+ return await navigator.clipboard.readText() === node.value;
248
+ });
249
+ return { matches };
250
+ }
251
+ const value = command.action === "clipboard" ? await this.page.evaluate(() => navigator.clipboard.readText()) : await field.evaluate((node) => {
252
+ if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
253
+ return node.value;
254
+ });
255
+ if (command.action === "check") {
256
+ this.registerSecrets(value);
257
+ return { matches: value === command.expected };
258
+ }
259
+ const sensitive = field && await field.evaluate(sensitiveField);
260
+ if (command.action === "clipboard") {
261
+ const fields = await this.page.$$("input,textarea,select");
262
+ let ordinary = false;
263
+ for (const candidate of fields) {
264
+ try {
265
+ if (!await candidate.evaluate(sensitiveField) && await candidate.inputValue() === value) ordinary = true;
266
+ } finally {
267
+ await candidate.dispose();
268
+ }
269
+ }
270
+ if (!ordinary) {
271
+ this.registerSecrets(value);
272
+ return { secret: secretMetadata(value) };
273
+ }
274
+ }
275
+ if (sensitive || [...this.secrets].some((secret) => value.includes(secret))) {
276
+ this.registerSecrets(value);
277
+ return { secret: secretMetadata(value) };
278
+ }
279
+ return { value };
280
+ } catch (error) {
281
+ if (error instanceof ControlError) throw error;
282
+ throw new ControlError("value_unreadable", "The field or clipboard could not be read. No comparison result is available.");
283
+ }
284
+ }
285
+ if (command.action === "paste") {
286
+ await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
287
+ const field = await this.resolve(command.reference);
288
+ if (!await field.evaluate(sensitiveField)) throw new ControlError("protected_target_required", "Paste requires a protected input or textarea.");
289
+ const editable = await field.evaluate((node) => (node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement) && !node.readOnly && !node.disabled);
290
+ if (!editable) throw new ControlError("value_unreadable", "The paste target is not editable.");
291
+ try {
292
+ const result = await field.evaluate(async (node) => {
293
+ const input = node;
294
+ const value = await navigator.clipboard.readText();
295
+ const owner = window;
296
+ const protectedValues = owner.__lmtPasteSecrets ??= [];
297
+ if (value && !protectedValues.includes(value)) protectedValues.push(value);
298
+ const prototype = input instanceof HTMLTextAreaElement ? HTMLTextAreaElement.prototype : HTMLInputElement.prototype;
299
+ Object.getOwnPropertyDescriptor(prototype, "value").set.call(input, value);
300
+ input.dispatchEvent(new Event("input", { bubbles: true }));
301
+ input.dispatchEvent(new Event("change", { bubbles: true }));
302
+ await new Promise((resolve) => requestAnimationFrame(() => resolve()));
303
+ if (!input.isConnected || input.value !== value) return null;
304
+ const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(value));
305
+ const fingerprint = value ? Array.from(new Uint8Array(digest)).map((byte) => byte.toString(16).padStart(2, "0")).join("").slice(0, 12) : null;
306
+ return { present: value.length > 0, length: value.length, fingerprint };
307
+ });
308
+ if (!result) throw new ControlError("paste_not_retained", "The field did not retain the pasted value.");
309
+ await this.learnSecrets();
310
+ return { secret: result, matches: true };
311
+ } catch (error) {
312
+ if (error instanceof ControlError) throw error;
313
+ throw new ControlError("value_unreadable", "The clipboard could not be pasted into the protected field.");
314
+ }
315
+ }
316
+ if (command.action === "fill") {
317
+ const element = await this.resolve(command.reference);
318
+ const sensitive = await element.evaluate(sensitiveField);
319
+ if (command.secret || sensitive) this.registerSecrets(command.value);
320
+ await element.fill(command.value, { timeout: timeoutMs });
321
+ return;
322
+ }
323
+ if (command.action === "select") {
324
+ await (await this.resolve(command.reference)).selectOption(command.value, { timeout: timeoutMs });
325
+ return;
326
+ }
327
+ if (command.action === "click") {
328
+ await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
329
+ const element = await this.resolve(command.reference);
330
+ const deadlineAt = Date.now() + timeoutMs;
331
+ await element.click({ trial: true, timeout: timeoutMs });
332
+ onSubmit();
333
+ try {
334
+ await element.click({ timeout: Math.max(1, deadlineAt - Date.now()) });
335
+ } catch {
336
+ throw new ControlError("action_uncertain", "The click may have been submitted but its completion was not confirmed. Inspect before deciding whether to retry.");
337
+ }
338
+ return;
339
+ }
340
+ if (command.action === "navigate") {
341
+ const target = new URL(command.url, this.origin);
342
+ if (target.origin !== new URL(this.origin).origin || target.username || target.password) {
343
+ throw new ControlError("invalid_target", "Navigation must use the session's configured Origin.");
344
+ }
345
+ await this.page.goto(target.href, { timeout: timeoutMs, waitUntil: "domcontentloaded" });
346
+ return;
347
+ }
348
+ if (command.action === "scroll") {
349
+ if (!Number.isFinite(command.x) || !Number.isFinite(command.y)) throw new ControlError("invalid_invocation", "Scroll offsets must be finite pixel values.");
350
+ const element = command.reference ? await this.resolve(command.reference) : void 0;
351
+ const scroll = await this.page.evaluate(({ element: node, offsets }) => {
352
+ const scrollable = (item) => {
353
+ const style = getComputedStyle(item);
354
+ return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX) && (item.scrollHeight > item.clientHeight || item.scrollWidth > item.clientWidth);
355
+ };
356
+ const containers = [];
357
+ if (node) {
358
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) if (scrollable(parent)) containers.push(parent);
359
+ if (document.scrollingElement && !containers.includes(document.scrollingElement)) containers.push(document.scrollingElement);
360
+ } else {
361
+ const candidates = Array.from(document.querySelectorAll("body *")).filter(scrollable);
362
+ const root = document.scrollingElement;
363
+ if (root && !/(hidden|clip)/.test(getComputedStyle(root).overflowY) && (root.scrollHeight > root.clientHeight || root.scrollWidth > root.clientWidth)) candidates.push(root);
364
+ candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
365
+ containers.push(candidates[0] ?? document.scrollingElement);
366
+ }
367
+ const positions = () => containers.map((item) => ({ x: item.scrollLeft, y: item.scrollTop }));
368
+ const before = positions();
369
+ if (node) node.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
370
+ else containers[0].scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" });
371
+ const after = positions();
372
+ const moved = before.some((position, i) => position.x !== after[i].x || position.y !== after[i].y);
373
+ if (node) {
374
+ const rect = node.getBoundingClientRect();
375
+ let left = Math.max(0, rect.left), right = Math.min(innerWidth, rect.right);
376
+ let top = Math.max(0, rect.top), bottom = Math.min(innerHeight, rect.bottom);
377
+ for (let parent = node.parentElement; parent; parent = parent.parentElement) {
378
+ const style = getComputedStyle(parent), clip = parent.getBoundingClientRect();
379
+ if (/(auto|scroll|hidden|clip)/.test(style.overflowX)) {
380
+ left = Math.max(left, clip.left);
381
+ right = Math.min(right, clip.right);
382
+ }
383
+ if (/(auto|scroll|hidden|clip)/.test(style.overflowY)) {
384
+ top = Math.max(top, clip.top);
385
+ bottom = Math.min(bottom, clip.bottom);
386
+ }
387
+ }
388
+ if (right <= left || bottom <= top) return null;
389
+ }
390
+ return { outcome: moved ? "moved" : node ? "already_visible" : "at_boundary", before, after };
391
+ }, { element: element ?? null, offsets: command });
392
+ await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
393
+ if (!scroll) throw new ControlError("target_unreachable", "The referenced control remains outside the visible scroll area.");
394
+ return { scroll };
395
+ }
396
+ }
397
+ };
398
+
399
+ // ../../packages/crawler/src/index.ts
400
+ import {
401
+ chromium
402
+ } from "playwright";
403
+ var DEFAULT_BASE_URL = "http://127.0.0.1:5173";
404
+ var DEFAULT_TIMEOUT_MS = 3e4;
405
+ var DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS = 5 * 6e4;
406
+ var OPERATOR_PAGE_SELECTOR = '[data-operator-page="true"]';
407
+ var MARKET_WATCH_SELECTOR = '[data-market-watch="true"]';
408
+ var OPERATOR_LOGIN_GATE_SELECTOR = '[data-operator-login-gate="true"]';
409
+ var OPERATOR_LOGIN_USERNAME_SELECTOR = "[data-operator-login-username]";
410
+ var OPERATOR_LOGIN_PASSWORD_SELECTOR = "[data-operator-login-password]";
411
+ var OPERATOR_LOGIN_CONFIRM_SELECTOR = "[data-operator-login-confirm]";
412
+ var CREDENTIALS_STATUS_SELECTOR = "[data-credentials-status]";
413
+ var ZERODHA_LOGIN_PAGE_SELECTOR = '[data-zerodha-login-page="true"]';
414
+ var OPERATOR_LOGIN_ATTEMPT_SELECTOR = "[data-operator-login-attempt]";
415
+ var OPERATOR_LOGIN_STATUS_ATTRIBUTE = "data-operator-login-status";
416
+ var OPERATOR_LOGIN_STATUS_MESSAGE_SELECTOR = '[data-operator-login-message-kind="status"]';
417
+ var OPERATOR_LOGIN_ERROR_MESSAGE_SELECTOR = '[data-operator-login-message-kind="error"]';
418
+ var OPERATOR_LOGIN_MESSAGE_ATTRIBUTE = "data-operator-login-message";
419
+ var OPERATOR_LOGIN_OTP_SELECTOR = "[data-operator-login-otp]";
420
+ var OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR = "[data-operator-login-otp-submit]";
421
+ var OPERATOR_LOGIN_CANCEL_SELECTOR = "[data-operator-login-cancel]";
422
+ var AUTHENTICATED_OPERATOR_LOGOUT_SELECTOR = "[data-authenticated-operator-logout]";
423
+ var UNAUTHENTICATED_OPERATOR_LOGOUT_SELECTOR = "[data-unauthenticated-operator-logout]";
424
+ var SETTINGS_SELECTOR = "[data-zc-settings]";
425
+ var OPERATOR_EGRESS_SELECTOR = "[data-operator-egress-value]";
426
+ var PEEK_TIMEOUT_MS = 250;
427
+ var MARKET_WATCH_LIVE_GRACE_MS = 3e3;
428
+ function sleep(ms) {
429
+ return new Promise((resolve) => setTimeout(resolve, ms));
430
+ }
431
+ async function isAttached(page, selector, attributeName) {
432
+ if (typeof page.waitForSelector === "function") {
433
+ try {
434
+ await page.waitForSelector(selector, { timeout: PEEK_TIMEOUT_MS });
435
+ return true;
436
+ } catch {
437
+ return false;
438
+ }
439
+ }
440
+ const value = await peekAttribute(page, selector, attributeName);
441
+ return value !== null;
442
+ }
443
+ async function peekAttribute(page, selector, name) {
444
+ try {
445
+ return await page.locator(selector).getAttribute(name, { timeout: PEEK_TIMEOUT_MS });
446
+ } catch {
447
+ return null;
448
+ }
449
+ }
450
+ function marketWatchRowSelector(sno) {
451
+ return `[data-market-watch-row="${sno}"]`;
452
+ }
453
+ var MARKET_WATCH_PANEL_SELECTOR = "[data-market-watch-panel]";
454
+ var MARKET_WATCH_ADD_INSTRUMENT_SELECTOR = "[data-market-watch-add-instrument]";
455
+ function marketWatchPanelSelector(sno) {
456
+ return `[data-market-watch-panel="${sno}"]`;
457
+ }
458
+ function marketWatchExchangeSelector(sno) {
459
+ return `${marketWatchPanelSelector(sno)} [data-market-watch-exchange="${sno}"]`;
460
+ }
461
+ function marketWatchTradingSymbolSelector(sno) {
462
+ return `${marketWatchPanelSelector(sno)} [data-market-watch-trading-symbol="${sno}"]`;
463
+ }
464
+ function marketWatchApplySelector(sno) {
465
+ return `${marketWatchPanelSelector(sno)} [data-market-watch-apply="${sno}"]`;
466
+ }
467
+ function marketWatchPanelCancelSelector(sno) {
468
+ return `${marketWatchPanelSelector(sno)} [data-market-watch-panel-cancel="${sno}"]`;
469
+ }
470
+ function marketWatchDraftErrorSelector(scope, sno) {
471
+ return `${scope} [data-market-watch-draft-error="${sno}"]`;
472
+ }
473
+ function marketWatchSelectRowSelector(sno) {
474
+ return `[data-market-watch-row="${sno}"] .op-page-row-toggle`;
475
+ }
476
+ function marketWatchDetailSelector(sno) {
477
+ return `[data-market-watch-detail="${sno}"]`;
478
+ }
479
+ var MARKET_WATCH_CONFIRM_YES_SELECTOR = "[data-market-watch-confirm-yes]";
480
+ function marketWatchCommittedSelector(sno) {
481
+ return `[data-market-watch-committed="${sno}"]`;
482
+ }
483
+ function marketWatchQuoteSelector(sno) {
484
+ return `[data-market-watch-quote="${sno}"]`;
485
+ }
486
+ function marketWatchQuoteFieldSelector(sno, field) {
487
+ return `${marketWatchQuoteSelector(sno)} [data-market-watch-quote-field="${field}"]`;
488
+ }
489
+ var QUOTE_FIELDS = {
490
+ ltp: "lastPrice",
491
+ open: "open",
492
+ high: "high",
493
+ low: "low",
494
+ close: "close",
495
+ volume: "volume",
496
+ "percentage-change": "percentageChange"
497
+ };
498
+ function toMarketWatchRowStatus(raw) {
499
+ switch (raw) {
500
+ case "Empty":
501
+ return "empty";
502
+ case "Validating":
503
+ return "validating";
504
+ case "Live":
505
+ return "live";
506
+ case "Error":
507
+ return "error";
508
+ case "Configured":
509
+ return "stopped";
510
+ case null:
511
+ throw new Error("Market Watch row did not expose a state");
512
+ default:
513
+ throw new Error(`Unknown Market Watch row state: ${raw}`);
514
+ }
515
+ }
516
+ var PlaywrightOperatorPage = class {
517
+ constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
518
+ this.page = page;
519
+ this.timeoutMs = timeoutMs;
520
+ this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
521
+ this.headless = headless;
522
+ this.registerCleanup = registerCleanup;
523
+ }
524
+ page;
525
+ timeoutMs;
526
+ interactiveLoginTimeoutMs;
527
+ headless;
528
+ registerCleanup;
529
+ async getOperatorInfo() {
530
+ await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
531
+ timeout: this.timeoutMs
532
+ });
533
+ const root = this.page.locator(OPERATOR_PAGE_SELECTOR);
534
+ const userId = (await root.getAttribute("data-user-id"))?.trim() ?? "";
535
+ const userName = (await root.getAttribute("data-user-name"))?.trim() ?? "";
536
+ if (!userId) {
537
+ throw new Error("Operator Page did not expose a user id");
538
+ }
539
+ return {
540
+ userId,
541
+ userName: userName || userId
542
+ };
543
+ }
544
+ async getMarketWatchRowCount() {
545
+ await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
546
+ timeout: this.timeoutMs
547
+ });
548
+ const raw = await this.page.locator(MARKET_WATCH_SELECTOR).getAttribute("data-market-watch-row-count", { timeout: this.timeoutMs });
549
+ const rowCount = Number(raw);
550
+ if (!Number.isInteger(rowCount) || rowCount < 1) {
551
+ throw new Error(`Market Watch exposed invalid row count: ${String(raw)}`);
552
+ }
553
+ return rowCount;
554
+ }
555
+ async getMarketWatchRowNumbers() {
556
+ const rowCount = await this.getMarketWatchRowCount();
557
+ return Array.from({ length: rowCount }, (_, index) => index + 1);
558
+ }
559
+ async getMarketWatchRow(sno) {
560
+ await this.waitForMarketWatch();
561
+ const rowSelector = marketWatchRowSelector(sno);
562
+ const listed = await isAttached(this.page, rowSelector, "data-market-watch-row-state");
563
+ const editing = await this.panelTargets(sno);
564
+ const draftError = (await peekAttribute(
565
+ this.page,
566
+ marketWatchDraftErrorSelector(editing ? marketWatchPanelSelector(sno) : rowSelector, sno),
567
+ "data-market-watch-draft-error-message"
568
+ ))?.trim();
569
+ const draft = editing ? {
570
+ exchange: await this.page.locator(marketWatchExchangeSelector(sno)).inputValue(),
571
+ tradingSymbol: await this.page.locator(marketWatchTradingSymbolSelector(sno)).inputValue()
572
+ } : void 0;
573
+ if (!listed) {
574
+ return {
575
+ sno,
576
+ exchange: draft?.exchange ?? "NSE",
577
+ tradingSymbol: draft?.tradingSymbol ?? "",
578
+ status: draftError ? "error" : "empty",
579
+ ...draftError ? { draftError } : {},
580
+ failureMarked: Boolean(draftError)
581
+ };
582
+ }
583
+ const row = this.page.locator(rowSelector);
584
+ const committed = this.page.locator(marketWatchCommittedSelector(sno));
585
+ const committedAttribute = (name2) => committed.getAttribute(name2, { timeout: PEEK_TIMEOUT_MS }).catch(() => null);
586
+ const match = (await committedAttribute("data-market-watch-match"))?.trim();
587
+ const token = (await committedAttribute("data-market-watch-token"))?.trim();
588
+ const name = (await committedAttribute("data-market-watch-name"))?.trim();
589
+ const segment = (await committedAttribute("data-market-watch-segment"))?.trim();
590
+ const quote = match && token ? await this.readMarketWatchQuote(sno, token) : void 0;
591
+ const separator = match?.indexOf(":") ?? -1;
592
+ return {
593
+ sno,
594
+ exchange: draft?.exchange ?? (separator > 0 ? match.slice(0, separator) : ""),
595
+ tradingSymbol: draft?.tradingSymbol ?? (separator > 0 ? match.slice(separator + 1) : ""),
596
+ status: toMarketWatchRowStatus(
597
+ await row.getAttribute("data-market-watch-row-state")
598
+ ),
599
+ ...draftError ? { draftError } : {},
600
+ failureMarked: await row.getAttribute("data-market-watch-row-failure") === "true",
601
+ ...quote ? { quote } : {},
602
+ ...match && token ? {
603
+ instrumentInfo: {
604
+ match,
605
+ token,
606
+ name: name ?? "",
607
+ segment: segment ?? ""
608
+ },
609
+ onTick: (listener) => {
610
+ const unsubscribe = this.subscribeLiveTicks(sno, token, listener);
611
+ this.registerCleanup(unsubscribe);
612
+ return unsubscribe;
613
+ }
614
+ } : {}
615
+ };
616
+ }
617
+ async setMarketWatchRow(sno, exchange, tradingSymbol) {
618
+ await this.waitForMarketWatch();
619
+ let slot = sno;
620
+ const listed = await isAttached(this.page, marketWatchRowSelector(sno), "data-market-watch-row-state");
621
+ if (listed) {
622
+ await this.removeMarketWatchRow(sno);
623
+ }
624
+ if (!await this.panelTargets(slot)) {
625
+ await this.page.locator(MARKET_WATCH_ADD_INSTRUMENT_SELECTOR).click();
626
+ const target = Number(await this.waitForPanel());
627
+ if (target !== slot) {
628
+ if (listed) {
629
+ slot = target;
630
+ } else {
631
+ await this.page.locator(marketWatchPanelCancelSelector(target)).click();
632
+ throw new Error(`Create Instrument targets slot ${target}, not ${sno}`);
633
+ }
634
+ }
635
+ }
636
+ await this.page.locator(marketWatchExchangeSelector(slot)).selectOption(exchange.trim().toUpperCase());
637
+ await this.page.locator(marketWatchTradingSymbolSelector(slot)).fill(tradingSymbol);
638
+ }
639
+ async applyMarketWatchRow(sno) {
640
+ await this.waitForMarketWatch();
641
+ await this.page.locator(marketWatchApplySelector(sno)).click();
642
+ const rowState = () => peekAttribute(this.page, marketWatchRowSelector(sno), "data-market-watch-row-state");
643
+ const deadline = Date.now() + this.timeoutMs;
644
+ let configuredSince = null;
645
+ while (Date.now() < deadline) {
646
+ if (await isAttached(this.page, marketWatchDraftErrorSelector(marketWatchPanelSelector(sno), sno), "data-market-watch-draft-error-message")) {
647
+ return;
648
+ }
649
+ if (!await this.panelTargets(sno)) {
650
+ const state = await rowState();
651
+ if (state === "Live" || state === "Error") return;
652
+ if (state === "Configured") {
653
+ configuredSince ??= Date.now();
654
+ if (Date.now() - configuredSince >= MARKET_WATCH_LIVE_GRACE_MS) return;
655
+ } else {
656
+ configuredSince = null;
657
+ }
658
+ }
659
+ await sleep(50);
660
+ }
661
+ if (configuredSince !== null && !await this.panelTargets(sno) && await rowState() === "Configured") return;
662
+ throw new Error(`Timed out waiting for Market Watch row ${sno} state`);
663
+ }
664
+ async removeMarketWatchRow(sno) {
665
+ await this.waitForMarketWatch();
666
+ await this.openRowDetails(sno);
667
+ await this.page.locator(`[data-market-watch-remove="${sno}"]`).click();
668
+ await this.page.locator(MARKET_WATCH_CONFIRM_YES_SELECTOR).click();
669
+ await this.waitForAttribute(
670
+ marketWatchRowSelector(sno),
671
+ "data-market-watch-row-state",
672
+ (state) => state === null,
673
+ `Market Watch row ${sno} to be removed`
674
+ );
675
+ }
676
+ /** @deprecated Operator UI no longer exposes Clear; removal deletes the row. */
677
+ async clearMarketWatchRow(sno) {
678
+ await this.removeMarketWatchRow(sno);
679
+ }
680
+ /** Market Watch drafts are loaded once the container exposes its row count. */
681
+ async waitForMarketWatch() {
682
+ await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
683
+ timeout: this.timeoutMs
684
+ });
685
+ await this.waitForAttribute(
686
+ MARKET_WATCH_SELECTOR,
687
+ "data-market-watch-row-count",
688
+ (value) => Number(value) >= 1,
689
+ "Market Watch rows"
690
+ );
691
+ }
692
+ async panelTargets(sno) {
693
+ return await peekAttribute(this.page, MARKET_WATCH_PANEL_SELECTOR, "data-market-watch-panel") === String(sno);
694
+ }
695
+ async waitForPanel() {
696
+ let target = null;
697
+ await this.waitForAttribute(
698
+ MARKET_WATCH_PANEL_SELECTOR,
699
+ "data-market-watch-panel",
700
+ (value) => (target = value) !== null,
701
+ "the Market Watch instrument panel"
702
+ );
703
+ return target;
704
+ }
705
+ async openRowDetails(sno) {
706
+ if (!await isAttached(this.page, marketWatchDetailSelector(sno), "data-market-watch-detail")) {
707
+ await this.page.locator(marketWatchSelectRowSelector(sno)).click();
708
+ }
709
+ }
710
+ async refresh() {
711
+ await this.page.reload();
712
+ await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
713
+ timeout: this.timeoutMs
714
+ });
715
+ await this.page.waitForSelector?.(MARKET_WATCH_SELECTOR, {
716
+ timeout: this.timeoutMs
717
+ });
718
+ }
719
+ async waitForAttribute(selector, attribute, predicate, description) {
720
+ const element = this.page.locator(selector);
721
+ const deadline = Date.now() + this.timeoutMs;
722
+ while (Date.now() < deadline) {
723
+ const value = await element.getAttribute(attribute, { timeout: PEEK_TIMEOUT_MS }).catch(() => null);
724
+ if (predicate(value)) {
725
+ return;
726
+ }
727
+ await sleep(50);
728
+ }
729
+ throw new Error(`Timed out waiting for ${description}`);
730
+ }
731
+ async readMarketWatchQuote(sno, fallbackToken) {
732
+ const root = this.page.locator(marketWatchQuoteSelector(sno));
733
+ const receivedAt = await root.getAttribute("data-market-watch-quote-received-at", {
734
+ timeout: PEEK_TIMEOUT_MS
735
+ }).catch(() => null);
736
+ if (!receivedAt) return void 0;
737
+ const receivedOn = new Date(receivedAt);
738
+ if (Number.isNaN(receivedOn.getTime())) {
739
+ throw new Error(`Market Watch row ${sno} exposed an invalid quote timestamp`);
740
+ }
741
+ const exchangeTimestampRaw = await root.getAttribute("data-market-watch-quote-exchange-time", {
742
+ timeout: PEEK_TIMEOUT_MS
743
+ }).catch(() => null);
744
+ const exchangeTimestamp = exchangeTimestampRaw ? new Date(exchangeTimestampRaw) : void 0;
745
+ if (exchangeTimestamp && Number.isNaN(exchangeTimestamp.getTime())) {
746
+ throw new Error(
747
+ `Market Watch row ${sno} exposed an invalid exchange tick timestamp`
748
+ );
749
+ }
750
+ const timestampStatusRaw = await root.getAttribute("data-market-watch-quote-timestamp-status", {
751
+ timeout: PEEK_TIMEOUT_MS
752
+ }).catch(() => null);
753
+ const missingTimestampCountRaw = await root.getAttribute("data-market-watch-quote-missing-timestamp-count", {
754
+ timeout: PEEK_TIMEOUT_MS
755
+ }).catch(() => null);
756
+ const updateTypeRaw = await root.getAttribute("data-market-watch-quote-update-type", {
757
+ timeout: PEEK_TIMEOUT_MS
758
+ }).catch(() => null);
759
+ const missingTimestampCount = Number(missingTimestampCountRaw);
760
+ if (missingTimestampCountRaw !== null && (!Number.isInteger(missingTimestampCount) || missingTimestampCount < 0)) {
761
+ throw new Error(
762
+ `Market Watch row ${sno} exposed an invalid missing timestamp count`
763
+ );
764
+ }
765
+ return {
766
+ receivedAt: receivedOn,
767
+ token: (await root.getAttribute("data-market-watch-quote-token", {
768
+ timeout: PEEK_TIMEOUT_MS
769
+ }).catch(() => null))?.trim() || fallbackToken,
770
+ mode: (await root.getAttribute("data-market-watch-quote-mode", {
771
+ timeout: PEEK_TIMEOUT_MS
772
+ }).catch(() => null))?.trim() || "full",
773
+ ...updateTypeRaw === "liveTick" || updateTypeRaw === "quoteSnapshot" || updateTypeRaw === "reconnectSnapshot" ? { latestUpdateType: updateTypeRaw } : {},
774
+ ...exchangeTimestamp ? { exchangeTimestamp } : {},
775
+ ...timestampStatusRaw === "available" || timestampStatusRaw === "missing" || timestampStatusRaw === "invalid" ? { exchangeTimestampStatus: timestampStatusRaw } : {},
776
+ ...missingTimestampCountRaw !== null ? { missingExchangeTimestampCount: missingTimestampCount } : {},
777
+ fields: await this.readQuoteFields(sno)
778
+ };
779
+ }
780
+ async readQuoteFields(sno) {
781
+ const fields = {};
782
+ for (const [domField, property] of Object.entries(QUOTE_FIELDS)) {
783
+ const raw = await this.page.locator(marketWatchQuoteFieldSelector(sno, domField)).getAttribute("data-market-watch-quote-value", {
784
+ timeout: PEEK_TIMEOUT_MS
785
+ }).catch(() => null);
786
+ if (raw === null || raw.trim() === "") continue;
787
+ const value = Number(raw);
788
+ if (!Number.isFinite(value)) {
789
+ throw new Error(
790
+ `Market Watch row ${sno} exposed a non-numeric ${domField} quote value`
791
+ );
792
+ }
793
+ fields[property] = value;
794
+ }
795
+ return fields;
796
+ }
797
+ subscribeLiveTicks(sno, expectedToken, listener) {
798
+ let stopped = false;
799
+ let lastFingerprint = "";
800
+ const poll = async () => {
801
+ while (!stopped) {
802
+ const quoteRoot = this.page.locator(marketWatchQuoteSelector(sno));
803
+ const receivedOnRaw = await quoteRoot.getAttribute("data-market-watch-quote-received-at", {
804
+ timeout: PEEK_TIMEOUT_MS
805
+ }).catch(() => null);
806
+ const token = await quoteRoot.getAttribute("data-market-watch-quote-token", {
807
+ timeout: PEEK_TIMEOUT_MS
808
+ }).catch(() => null);
809
+ const mode = await quoteRoot.getAttribute("data-market-watch-quote-mode", {
810
+ timeout: PEEK_TIMEOUT_MS
811
+ }).catch(() => null);
812
+ const exchangeTimestampRaw = await quoteRoot.getAttribute("data-market-watch-quote-exchange-time", {
813
+ timeout: PEEK_TIMEOUT_MS
814
+ }).catch(() => null);
815
+ const timestampStatusRaw = await quoteRoot.getAttribute("data-market-watch-quote-timestamp-status", {
816
+ timeout: PEEK_TIMEOUT_MS
817
+ }).catch(() => null);
818
+ const missingTimestampCountRaw = await quoteRoot.getAttribute("data-market-watch-quote-missing-timestamp-count", {
819
+ timeout: PEEK_TIMEOUT_MS
820
+ }).catch(() => null);
821
+ const updateTypeRaw = await quoteRoot.getAttribute("data-market-watch-quote-update-type", {
822
+ timeout: PEEK_TIMEOUT_MS
823
+ }).catch(() => null);
824
+ if (receivedOnRaw && token === expectedToken) {
825
+ const actualMode = mode?.trim() || "";
826
+ const fingerprint = `${receivedOnRaw}|${token}|${actualMode}|${updateTypeRaw ?? ""}|${exchangeTimestampRaw ?? ""}|${timestampStatusRaw ?? ""}`;
827
+ if (fingerprint !== lastFingerprint) {
828
+ lastFingerprint = fingerprint;
829
+ const fields = await this.readQuoteFields(sno);
830
+ const exchangeTimestamp = exchangeTimestampRaw ? new Date(exchangeTimestampRaw) : void 0;
831
+ const missingExchangeTimestampCount = Number(
832
+ missingTimestampCountRaw
833
+ );
834
+ const tick = {
835
+ receivedOn: new Date(receivedOnRaw),
836
+ token,
837
+ mode: actualMode,
838
+ ...exchangeTimestamp && !Number.isNaN(exchangeTimestamp.getTime()) ? { exchangeTimestamp } : {},
839
+ ...timestampStatusRaw === "available" || timestampStatusRaw === "missing" || timestampStatusRaw === "invalid" ? { exchangeTimestampStatus: timestampStatusRaw } : {},
840
+ ...Number.isInteger(missingExchangeTimestampCount) && missingExchangeTimestampCount >= 0 ? { missingExchangeTimestampCount } : {},
841
+ ...fields
842
+ };
843
+ if (tick.mode === "full" && updateTypeRaw === "liveTick" && timestampStatusRaw === "available" && tick.exchangeTimestamp && typeof tick.lastPrice === "number" && Number.isFinite(tick.lastPrice) && tick.lastPrice > 0) {
844
+ listener(tick);
845
+ }
846
+ }
847
+ }
848
+ await sleep(50);
849
+ }
850
+ };
851
+ void poll();
852
+ return () => {
853
+ stopped = true;
854
+ };
855
+ }
856
+ async clickLogoutZerodha(onSubmit = () => void 0) {
857
+ await this.page.waitForSelector?.(OPERATOR_PAGE_SELECTOR, {
858
+ timeout: this.timeoutMs
859
+ });
860
+ await this.page.locator("[data-operator-info-toggle]").click();
861
+ onSubmit();
862
+ await this.page.locator(AUTHENTICATED_OPERATOR_LOGOUT_SELECTOR).click();
863
+ await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
864
+ timeout: this.timeoutMs
865
+ });
866
+ return new PlaywrightZerodhaLoginPage(
867
+ this.page,
868
+ this.timeoutMs,
869
+ this.interactiveLoginTimeoutMs,
870
+ this.headless,
871
+ this.registerCleanup
872
+ );
873
+ }
874
+ };
875
+ var LoginAttemptError = class extends Error {
876
+ constructor(code, message) {
877
+ super(message);
878
+ this.code = code;
879
+ }
880
+ code;
881
+ };
882
+ var PlaywrightZerodhaLoginPage = class {
883
+ constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
884
+ this.page = page;
885
+ this.timeoutMs = timeoutMs;
886
+ this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
887
+ this.headless = headless;
888
+ this.registerCleanup = registerCleanup;
889
+ }
890
+ page;
891
+ timeoutMs;
892
+ interactiveLoginTimeoutMs;
893
+ headless;
894
+ registerCleanup;
895
+ async getOperatorEgress() {
896
+ await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
897
+ timeout: this.timeoutMs
898
+ });
899
+ if (!await isAttached(
900
+ this.page,
901
+ OPERATOR_EGRESS_SELECTOR,
902
+ "data-operator-egress-value"
903
+ )) {
904
+ await this.page.locator(SETTINGS_SELECTOR).click();
905
+ }
906
+ await this.page.waitForSelector?.(OPERATOR_EGRESS_SELECTOR, {
907
+ timeout: this.timeoutMs
908
+ });
909
+ const field = this.page.locator(OPERATOR_EGRESS_SELECTOR);
910
+ return {
911
+ publicIp: await field.inputValue(),
912
+ readOnly: await field.getAttribute("readonly") !== null
913
+ };
914
+ }
915
+ async peekLoginStatus() {
916
+ return peekAttribute(
917
+ this.page,
918
+ OPERATOR_LOGIN_ATTEMPT_SELECTOR,
919
+ OPERATOR_LOGIN_STATUS_ATTRIBUTE
920
+ );
921
+ }
922
+ async peekLoginMessage() {
923
+ return await this.peekLoginStatusMessage() ?? await this.peekLoginErrorMessage();
924
+ }
925
+ async peekLoginStatusMessage() {
926
+ const message = await peekAttribute(
927
+ this.page,
928
+ OPERATOR_LOGIN_STATUS_MESSAGE_SELECTOR,
929
+ OPERATOR_LOGIN_MESSAGE_ATTRIBUTE
930
+ );
931
+ return message?.trim() || null;
932
+ }
933
+ async peekLoginErrorMessage() {
934
+ const message = await peekAttribute(
935
+ this.page,
936
+ OPERATOR_LOGIN_ERROR_MESSAGE_SELECTOR,
937
+ OPERATOR_LOGIN_MESSAGE_ATTRIBUTE
938
+ );
939
+ return message?.trim() || null;
940
+ }
941
+ async waitForLoginStatus(predicate, description) {
942
+ const deadline = Date.now() + this.interactiveLoginTimeoutMs;
943
+ while (Date.now() < deadline) {
944
+ const status = await this.peekLoginStatus();
945
+ if (predicate(status)) {
946
+ return;
947
+ }
948
+ const message = await this.peekLoginMessage();
949
+ if (status === "failed" || status === null && message) {
950
+ throw new Error(message ?? "Zerodha Login Attempt failed.");
951
+ }
952
+ await sleep(50);
953
+ }
954
+ throw new Error(`Timed out waiting for ${description}`);
955
+ }
956
+ async waitForLoginAttempt() {
957
+ const deadline = Date.now() + this.interactiveLoginTimeoutMs;
958
+ while (Date.now() < deadline) {
959
+ if (await isAttached(
960
+ this.page,
961
+ OPERATOR_LOGIN_ATTEMPT_SELECTOR,
962
+ "data-operator-login-attempt"
963
+ )) {
964
+ return;
965
+ }
966
+ const message = await this.peekLoginMessage();
967
+ if (message) {
968
+ throw new Error(message);
969
+ }
970
+ await sleep(50);
971
+ }
972
+ throw new Error("Timed out waiting for the Zerodha Login Attempt");
973
+ }
974
+ /** Start Zerodha Login Attempt for this Operator Session (always OTP path). */
975
+ async clickLoginWithZerodha() {
976
+ await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
977
+ timeout: this.timeoutMs
978
+ });
979
+ if (await isAttached(
980
+ this.page,
981
+ OPERATOR_LOGIN_ATTEMPT_SELECTOR,
982
+ "data-operator-login-attempt"
983
+ )) {
984
+ return;
985
+ }
986
+ await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
987
+ await this.waitForLoginAttempt();
988
+ }
989
+ async enterOtp(otp) {
990
+ await this.clickLoginWithZerodha();
991
+ await this.waitForLoginStatus(
992
+ (status) => status === "awaiting_otp",
993
+ "the Zerodha OTP Challenge"
994
+ );
995
+ await this.page.locator(OPERATOR_LOGIN_OTP_SELECTOR).fill(otp);
996
+ }
997
+ async submitOtp() {
998
+ const previousStatusMessage = await this.peekLoginStatusMessage();
999
+ await this.page.locator(OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR).click();
1000
+ const deadline = Date.now() + this.interactiveLoginTimeoutMs;
1001
+ while (Date.now() < deadline) {
1002
+ if (await isAttached(this.page, OPERATOR_PAGE_SELECTOR, "data-operator-page")) {
1003
+ return {
1004
+ status: "SUCCESS",
1005
+ operatorPage: new PlaywrightOperatorPage(
1006
+ this.page,
1007
+ this.timeoutMs,
1008
+ this.interactiveLoginTimeoutMs,
1009
+ this.headless,
1010
+ this.registerCleanup
1011
+ )
1012
+ };
1013
+ }
1014
+ const status = await this.peekLoginStatus();
1015
+ const errorMessage = await this.peekLoginErrorMessage();
1016
+ if (errorMessage) {
1017
+ return { status: "FAILED", message: errorMessage };
1018
+ }
1019
+ const statusMessage = await this.peekLoginStatusMessage();
1020
+ if (status === "failed") {
1021
+ return {
1022
+ status: "FAILED",
1023
+ message: statusMessage ?? "Zerodha Login Attempt failed."
1024
+ };
1025
+ }
1026
+ if (statusMessage && statusMessage !== previousStatusMessage) {
1027
+ return { status: "FAILED", message: statusMessage };
1028
+ }
1029
+ await sleep(50);
1030
+ }
1031
+ throw new Error("Timed out waiting for the Zerodha OTP submission result");
1032
+ }
1033
+ async observeLoginState() {
1034
+ if (!this.page.evaluate) throw new Error("Browser inspection is unavailable.");
1035
+ return this.page.evaluate(() => {
1036
+ const operatorPage = !!document.querySelector('[data-operator-page="true"]');
1037
+ const transitioning = !!document.querySelector('[data-session-loading], [data-zerodha-restoration="pending"]');
1038
+ const attempt = document.querySelector("[data-operator-login-attempt]");
1039
+ const attemptId = attempt?.getAttribute("data-operator-login-attempt-id");
1040
+ return { operatorPage, transitioning, attempt: !attempt || !attemptId ? null : {
1041
+ attemptId,
1042
+ status: attempt.getAttribute("data-operator-login-status") ?? "unavailable",
1043
+ failedOtpAttempts: Number(attempt.getAttribute("data-operator-login-failed-otp-attempts") ?? 0),
1044
+ submitting: attempt.getAttribute("data-operator-login-submitting-otp") === "true",
1045
+ message: document.querySelector('[data-operator-login-message-kind="error"]')?.getAttribute("data-operator-login-message") ?? null
1046
+ } };
1047
+ });
1048
+ }
1049
+ async observeLoginAttempt() {
1050
+ return (await this.observeLoginState()).attempt;
1051
+ }
1052
+ async startLoginAttempt(onSubmit = () => void 0) {
1053
+ const deadline = Date.now() + this.timeoutMs;
1054
+ const existing = await this.observeLoginAttempt();
1055
+ let observedId = existing?.attemptId;
1056
+ if (!existing) {
1057
+ await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, { timeout: this.timeoutMs });
1058
+ onSubmit();
1059
+ await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
1060
+ }
1061
+ while (Date.now() < deadline) {
1062
+ const current = await this.observeLoginAttempt();
1063
+ if (observedId && current?.attemptId !== observedId) throw new LoginAttemptError("attempt_replaced", "The Zerodha Login Attempt was replaced. Inspect the page before starting another attempt.");
1064
+ observedId ??= current?.attemptId;
1065
+ if (current?.status === "awaiting_otp") return current;
1066
+ if (current && !["loading", "awaiting_otp", "succeeded", "failed"].includes(current.status)) throw new LoginAttemptError("attempt_unavailable", "The Zerodha Login Attempt is expired or unavailable. No replacement was started.");
1067
+ if (current?.status === "failed") throw new LoginAttemptError("attempt_failed", "The Zerodha Login Attempt failed. Inspect the visible error before starting another attempt.");
1068
+ await sleep(50);
1069
+ }
1070
+ throw new Error("Timed out waiting for the Zerodha OTP Challenge.");
1071
+ }
1072
+ /** Cancels the visible Zerodha Login Attempt through its Cancel login control; a page without one is left unchanged. */
1073
+ async cancelLoginAttempt(onSubmit = () => void 0) {
1074
+ const deadline = Date.now() + this.timeoutMs;
1075
+ const existing = await this.observeLoginAttempt();
1076
+ if (!existing) return;
1077
+ if (!this.page.evaluate) throw new Error("Browser inspection is unavailable.");
1078
+ const selector = `${OPERATOR_LOGIN_ATTEMPT_SELECTOR} ${OPERATOR_LOGIN_CANCEL_SELECTOR}`;
1079
+ const cancellable = await this.page.evaluate(() => {
1080
+ const button = document.querySelector("[data-operator-login-attempt] [data-operator-login-cancel]");
1081
+ return !!button && !button.disabled;
1082
+ });
1083
+ if (!cancellable) {
1084
+ throw new LoginAttemptError("attempt_not_cancellable", "The Zerodha Login Attempt has no Cancel login control in its current state. Inspect the page.");
1085
+ }
1086
+ onSubmit();
1087
+ await this.page.locator(selector).click();
1088
+ while (Date.now() < deadline) {
1089
+ const current = await this.observeLoginAttempt();
1090
+ if (current?.attemptId !== existing.attemptId) return;
1091
+ await sleep(50);
1092
+ }
1093
+ throw new Error("Timed out waiting for the Zerodha Login Attempt to be cancelled.");
1094
+ }
1095
+ async submitActiveOtp(attemptId, otp, onSubmit = () => void 0) {
1096
+ const deadline = Date.now() + this.timeoutMs;
1097
+ const assertActive = async () => {
1098
+ if (Date.now() >= deadline) throw new Error("OTP submission exceeded its deadline before submission.");
1099
+ const current = await this.observeLoginAttempt();
1100
+ if (!current) throw new LoginAttemptError("attempt_unavailable", "The requested OTP Challenge is unavailable. Inspect the page; no login was started.");
1101
+ if (current.attemptId !== attemptId) throw new LoginAttemptError("attempt_replaced", "The requested OTP Challenge has been replaced. No OTP was submitted.");
1102
+ if (current.status !== "awaiting_otp" || current.submitting) throw new LoginAttemptError("attempt_unavailable", "The requested OTP Challenge is expired, failed, or not accepting an OTP.");
1103
+ return current;
1104
+ };
1105
+ const before = await assertActive();
1106
+ const attemptSelector = `[data-operator-login-attempt-id=${JSON.stringify(attemptId)}]`;
1107
+ await this.page.locator(`${attemptSelector} ${OPERATOR_LOGIN_OTP_SELECTOR}`).fill(otp);
1108
+ await assertActive();
1109
+ onSubmit();
1110
+ await this.page.locator(`${attemptSelector} ${OPERATOR_LOGIN_OTP_SUBMIT_SELECTOR}`).click();
1111
+ while (Date.now() < deadline) {
1112
+ const state = await this.observeLoginState();
1113
+ if (state.operatorPage) {
1114
+ return { status: "SUCCESS", operatorPage: new PlaywrightOperatorPage(this.page, this.timeoutMs) };
1115
+ }
1116
+ const current = state.attempt;
1117
+ if (state.transitioning || current?.status === "succeeded") {
1118
+ await sleep(50);
1119
+ continue;
1120
+ }
1121
+ if (!current) throw new LoginAttemptError("attempt_unavailable", "The OTP Challenge disappeared after submission. Inspect the page before taking another action.");
1122
+ if (current.attemptId !== attemptId) throw new LoginAttemptError("attempt_replaced", "The OTP Challenge was replaced after submission. No retry was performed.");
1123
+ if (current.status === "failed") return { status: "FAILED", message: current.message ?? "Zerodha Login Attempt failed." };
1124
+ if (!current.submitting && current.failedOtpAttempts > before.failedOtpAttempts) return { status: "FAILED", message: current.message ?? "OTP rejected." };
1125
+ await sleep(50);
1126
+ }
1127
+ throw new Error("Timed out waiting for the OTP submission result. No retry was performed.");
1128
+ }
1129
+ async clickLogoutAccount(onSubmit = () => void 0) {
1130
+ await this.page.waitForSelector?.(ZERODHA_LOGIN_PAGE_SELECTOR, {
1131
+ timeout: this.timeoutMs
1132
+ });
1133
+ onSubmit();
1134
+ await this.page.locator(UNAUTHENTICATED_OPERATOR_LOGOUT_SELECTOR).click();
1135
+ await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
1136
+ timeout: this.timeoutMs
1137
+ });
1138
+ return new PlaywrightHomePage(
1139
+ this.page,
1140
+ this.timeoutMs,
1141
+ this.interactiveLoginTimeoutMs,
1142
+ this.headless,
1143
+ this.registerCleanup
1144
+ );
1145
+ }
1146
+ };
1147
+ var PlaywrightHomePage = class {
1148
+ constructor(page, timeoutMs = DEFAULT_TIMEOUT_MS, interactiveLoginTimeoutMs = DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS, headless = true, registerCleanup = () => void 0) {
1149
+ this.page = page;
1150
+ this.timeoutMs = timeoutMs;
1151
+ this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
1152
+ this.headless = headless;
1153
+ this.registerCleanup = registerCleanup;
1154
+ }
1155
+ page;
1156
+ timeoutMs;
1157
+ interactiveLoginTimeoutMs;
1158
+ headless;
1159
+ registerCleanup;
1160
+ async enterUsername(username) {
1161
+ await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
1162
+ timeout: this.timeoutMs
1163
+ });
1164
+ await this.page.locator(OPERATOR_LOGIN_USERNAME_SELECTOR).fill(username);
1165
+ }
1166
+ async enterPassword(password) {
1167
+ await this.page.waitForSelector?.(OPERATOR_LOGIN_GATE_SELECTOR, {
1168
+ timeout: this.timeoutMs
1169
+ });
1170
+ await this.page.locator(OPERATOR_LOGIN_PASSWORD_SELECTOR).fill(password);
1171
+ }
1172
+ async clickConfirm() {
1173
+ await this.page.locator(OPERATOR_LOGIN_CONFIRM_SELECTOR).click();
1174
+ const deadline = Date.now() + this.timeoutMs;
1175
+ while (Date.now() < deadline) {
1176
+ const credentialsStatus = await peekAttribute(
1177
+ this.page,
1178
+ CREDENTIALS_STATUS_SELECTOR,
1179
+ "data-credentials-status"
1180
+ );
1181
+ if (credentialsStatus === "INCORRECT_CREDENTIALS") {
1182
+ return { status: "INCORRECT_CREDENTIALS" };
1183
+ }
1184
+ if (await isAttached(
1185
+ this.page,
1186
+ ZERODHA_LOGIN_PAGE_SELECTOR,
1187
+ "data-zerodha-login-page"
1188
+ )) {
1189
+ return {
1190
+ status: "SUCCESS",
1191
+ zerodhaLoginPage: new PlaywrightZerodhaLoginPage(
1192
+ this.page,
1193
+ this.timeoutMs,
1194
+ this.interactiveLoginTimeoutMs,
1195
+ this.headless,
1196
+ this.registerCleanup
1197
+ )
1198
+ };
1199
+ }
1200
+ await sleep(50);
1201
+ }
1202
+ throw new Error(
1203
+ "Timed out waiting for the Zerodha Login Page after Operator Login confirm"
1204
+ );
1205
+ }
1206
+ };
1207
+ var PlaywrightCrawler = class {
1208
+ constructor(browser, page, baseUrl, timeoutMs, interactiveLoginTimeoutMs, headless) {
1209
+ this.browser = browser;
1210
+ this.page = page;
1211
+ this.baseUrl = baseUrl;
1212
+ this.timeoutMs = timeoutMs;
1213
+ this.interactiveLoginTimeoutMs = interactiveLoginTimeoutMs;
1214
+ this.headless = headless;
1215
+ }
1216
+ browser;
1217
+ page;
1218
+ baseUrl;
1219
+ timeoutMs;
1220
+ interactiveLoginTimeoutMs;
1221
+ headless;
1222
+ cleanups = /* @__PURE__ */ new Set();
1223
+ pageControls;
1224
+ async openHomePage() {
1225
+ await this.page.goto(this.baseUrl);
1226
+ return new PlaywrightHomePage(
1227
+ this.page,
1228
+ this.timeoutMs,
1229
+ this.interactiveLoginTimeoutMs,
1230
+ this.headless,
1231
+ (cleanup) => this.cleanups.add(cleanup)
1232
+ );
1233
+ }
1234
+ controls() {
1235
+ return this.pageControls ??= new PageControls(this.page, this.baseUrl);
1236
+ }
1237
+ getPage() {
1238
+ return this.page;
1239
+ }
1240
+ async close() {
1241
+ for (const cleanup of this.cleanups) {
1242
+ cleanup();
1243
+ }
1244
+ this.cleanups.clear();
1245
+ await this.browser.close();
1246
+ }
1247
+ async forceClose() {
1248
+ if (this.browser.forceClose) {
1249
+ await this.browser.forceClose();
1250
+ return;
1251
+ }
1252
+ await this.browser.close();
1253
+ }
1254
+ };
1255
+ async function createCrawler(options = {}) {
1256
+ const headless = options.headless ?? true;
1257
+ const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1258
+ const launchTimeoutMs = options.launchTimeoutMs ?? DEFAULT_TIMEOUT_MS;
1259
+ const interactiveLoginTimeoutMs = options.interactiveLoginTimeoutMs ?? DEFAULT_INTERACTIVE_LOGIN_TIMEOUT_MS;
1260
+ const browser = await (options.launch ?? (() => chromium.launch({ headless, timeout: launchTimeoutMs })))();
1261
+ const page = await browser.newPage(options.contextOptions);
1262
+ page.setDefaultTimeout?.(timeoutMs);
1263
+ page.setDefaultNavigationTimeout?.(timeoutMs);
1264
+ return new PlaywrightCrawler(
1265
+ browser,
1266
+ page,
1267
+ options.baseUrl ?? DEFAULT_BASE_URL,
1268
+ timeoutMs,
1269
+ interactiveLoginTimeoutMs,
1270
+ headless
1271
+ );
1272
+ }
1273
+
1274
+ // src/web/protocol.ts
1275
+ var RESULT_VERSION = 1;
1276
+ function failureResult(action, code, message, sessionId = null) {
1277
+ return {
1278
+ version: RESULT_VERSION,
1279
+ sessionId,
1280
+ action,
1281
+ outcome: "failure",
1282
+ authentication: { status: "unknown" },
1283
+ url: null,
1284
+ visibleState: null,
1285
+ device: null,
1286
+ screenshot: { outcome: "unavailable", reason: "No browser page was available." },
1287
+ error: { code, message }
1288
+ };
1289
+ }
1290
+
1291
+ // src/web/actions.ts
1292
+ var InvocationError = class extends Error {
1293
+ constructor(message, code = "invalid_invocation", action) {
1294
+ super(message);
1295
+ this.code = code;
1296
+ this.action = action;
1297
+ }
1298
+ code;
1299
+ action;
1300
+ };
1301
+ function required(input, option) {
1302
+ const value = input.values.get(option);
1303
+ if (value === void 0 || option !== "--value" && !value) throw new InvocationError(`${option} is required for this action.`);
1304
+ return value;
1305
+ }
1306
+ var ACTIONS = {
1307
+ start: { control: false, flags: ["--headed", "--fresh"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
1308
+ inspect: { control: false, flags: [], values: [], build: () => ({ action: "inspect" }) },
1309
+ close: { control: false, flags: [], values: [], build: () => ({ action: "close" }) },
1310
+ stop: { control: false, flags: [], values: [], build: () => ({ action: "stop" }) },
1311
+ "operator-login": { control: false, flags: [], values: [], build: () => ({ action: "operator-login" }) },
1312
+ "operator-logout": { control: false, flags: [], values: [], build: () => ({ action: "operator-logout" }) },
1313
+ "zerodha-login": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-login" }) },
1314
+ "zerodha-cancel": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-cancel" }) },
1315
+ "zerodha-logout": { control: false, flags: [], values: [], build: () => ({ action: "zerodha-logout" }) },
1316
+ "zerodha-otp": {
1317
+ control: false,
1318
+ flags: ["--stdin"],
1319
+ values: ["--attempt"],
1320
+ build: async (input) => {
1321
+ const attemptId = required(input, "--attempt");
1322
+ if (!input.flags.has("--stdin")) throw new InvocationError("zerodha-otp requires --attempt ID and --stdin.");
1323
+ return { action: "zerodha-otp", attemptId, otp: await input.readOtp() };
1324
+ }
1325
+ },
1326
+ read: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "read", reference: required(input, "--ref") }) },
1327
+ clipboard: { control: true, flags: [], values: [], build: () => ({ action: "clipboard" }) },
1328
+ paste: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "paste", reference: required(input, "--ref") }) },
1329
+ check: { control: true, flags: ["--stdin"], values: ["--ref"], build: async (input) => {
1330
+ if (!input.flags.has("--stdin")) throw new InvocationError("check requires --stdin.");
1331
+ return { action: "check", reference: required(input, "--ref"), expected: await input.readInput() };
1332
+ } },
1333
+ "clipboard-check": { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "clipboard-check", reference: required(input, "--ref") }) },
1334
+ click: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "click", reference: required(input, "--ref") }) },
1335
+ fill: {
1336
+ control: true,
1337
+ flags: ["--secret"],
1338
+ values: ["--ref", "--value"],
1339
+ build: (input) => ({ action: "fill", reference: required(input, "--ref"), value: required(input, "--value"), secret: input.flags.has("--secret") })
1340
+ },
1341
+ select: {
1342
+ control: true,
1343
+ flags: [],
1344
+ values: ["--ref", "--value"],
1345
+ build: (input) => ({ action: "select", reference: required(input, "--ref"), value: required(input, "--value") })
1346
+ },
1347
+ scroll: {
1348
+ control: true,
1349
+ flags: [],
1350
+ values: ["--ref", "--x", "--y"],
1351
+ build: (input) => ({ action: "scroll", reference: input.values.get("--ref"), x: Number(input.values.get("--x") ?? 0), y: Number(input.values.get("--y") ?? 0) })
1352
+ },
1353
+ navigate: { control: true, flags: [], values: ["--url"], build: (input) => ({ action: "navigate", url: required(input, "--url") }) }
1354
+ };
1355
+ function isAction(value) {
1356
+ return typeof value === "string" && Object.hasOwn(ACTIONS, value);
1357
+ }
1358
+ function isControlAction(action) {
1359
+ return ACTIONS[action.action].control;
1360
+ }
1361
+
1362
+ // src/web/operator.ts
1363
+ var OperatorActionError = class extends Error {
1364
+ constructor(code, message) {
1365
+ super(message);
1366
+ this.code = code;
1367
+ }
1368
+ code;
1369
+ };
1370
+ async function operatorLogin(input) {
1371
+ const deadlineAt = Date.now() + input.timeoutMs;
1372
+ const remaining = () => {
1373
+ const timeout = deadlineAt - Date.now();
1374
+ if (timeout < 1) throw new Error("Operator login exceeded its deadline before submission.");
1375
+ return timeout;
1376
+ };
1377
+ let target;
1378
+ try {
1379
+ target = resolveCliTarget({ LMT_ENV: input.environment });
1380
+ if (target.origin !== input.origin) throw new Error("The configured Web URL changed. Start a new browser session.");
1381
+ } catch {
1382
+ throw new OperatorActionError("invalid_sms_configuration", "Check smsEndpoint, Gate scope, and OPERATOR_1 in the selected Web configuration, then start a fresh session.");
1383
+ }
1384
+ let credential;
1385
+ try {
1386
+ credential = await new CliProfileStore().getSmsCredential();
1387
+ } catch {
1388
+ throw new OperatorActionError("sms_credential_unavailable", "Could not read protected SMS storage. Provision the SMS read credential again.");
1389
+ }
1390
+ if (!credential) throw new OperatorActionError("sms_credential_missing", "Provision the SMS read credential with `lmt credential provision` before Operator login.");
1391
+ input.registerSecrets(credential);
1392
+ let credentials;
1393
+ try {
1394
+ credentials = await readOperatorLoginCredentials({
1395
+ operatorId: target.operatorId,
1396
+ gateScope: target.gateScope,
1397
+ smsEndpoint: target.smsEndpoint,
1398
+ smsCredential: credential,
1399
+ timeoutMs: remaining(),
1400
+ ...process.env.LMT_WEB_SMS_COMMAND ? { command: process.env.LMT_WEB_SMS_COMMAND } : {}
1401
+ });
1402
+ } catch (error) {
1403
+ throw new OperatorActionError(error instanceof SmsReadError ? error.code : "sms_unavailable", "Could not load Operator credentials from the selected Gate SMS scope. Check provisioning and SMS reachability.");
1404
+ }
1405
+ input.registerSecrets(credentials.username, credentials.password);
1406
+ const home = new PlaywrightHomePage(input.page, remaining());
1407
+ await home.enterUsername(credentials.username);
1408
+ await home.enterPassword(credentials.password);
1409
+ remaining();
1410
+ input.onSubmit?.();
1411
+ const result = await home.clickConfirm();
1412
+ if (result.status !== "SUCCESS") throw new OperatorActionError("incorrect_credentials", "The Home Page rejected the configured Operator credentials. Check the selected Gate Operator Directory.");
1413
+ }
1414
+ async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
1415
+ const isOperatorPage = await page.evaluate?.(() => !!document.querySelector('[data-operator-page="true"]'));
1416
+ if (isOperatorPage) {
1417
+ await new PlaywrightOperatorPage(page, timeoutMs).clickLogoutZerodha(onSubmit);
1418
+ }
1419
+ await new PlaywrightZerodhaLoginPage(page, timeoutMs).clickLogoutAccount(onSubmit);
1420
+ }
1421
+
1422
+ // src/web/session.ts
1423
+ var SESSION_DIRECTORY = path.join(os.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
1424
+ var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
1425
+ var OWNER_RESPONSE_GRACE_MS = 1e3;
1426
+ var OWNER_CLEANUP_GRACE_MS = 2e3;
1427
+ var IPHONE_13_USER_AGENT = "Mozilla/5.0 (iPhone; CPU iPhone OS 15_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/26.5 Mobile/15E148 Safari/604.1";
1428
+ function createSessionId() {
1429
+ return randomBytes2(16).toString("hex");
1430
+ }
1431
+ function isSessionId(value) {
1432
+ return !!value && SESSION_ID_PATTERN.test(value);
1433
+ }
1434
+ async function ensureSessionDirectory() {
1435
+ if (process.platform === "win32") {
1436
+ throw new Error("lmt web session sockets are supported on macOS and Linux.");
1437
+ }
1438
+ await mkdir(SESSION_DIRECTORY, { recursive: true, mode: 448 });
1439
+ await chmod(SESSION_DIRECTORY, 448);
1440
+ const directory = await lstat(SESSION_DIRECTORY);
1441
+ const uid = process.getuid?.();
1442
+ if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
1443
+ throw new Error("The lmt web session directory is not owned by the invoking user.");
1444
+ }
1445
+ }
1446
+ function socketPathFor(sessionId) {
1447
+ return path.join(SESSION_DIRECTORY, `${sessionId}.sock`);
1448
+ }
1449
+ function artifactsDirectory(sessionId) {
1450
+ return path.join(SESSION_DIRECTORY, "artifacts", sessionId);
1451
+ }
1452
+ function sanitizeUrls(value) {
1453
+ return value.replace(/https?:\/\/[^\s)'"<>]+/gi, (candidate) => safeUrl(candidate) ?? "[url]");
1454
+ }
1455
+ function safeUrl(value) {
1456
+ try {
1457
+ const url = new URL(value);
1458
+ if (!/^https?:$/.test(url.protocol) || url.origin === "null") return null;
1459
+ return `${url.origin}${url.pathname}`;
1460
+ } catch {
1461
+ return null;
1462
+ }
1463
+ }
1464
+ function failureCode(error) {
1465
+ if (error instanceof ControlError) return error.code;
1466
+ const message = error instanceof Error ? error.message : "";
1467
+ if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
1468
+ if (/executable doesn't exist|browser.*not.*installed|could not find.*browser/i.test(message)) {
1469
+ return "browser_unavailable";
1470
+ }
1471
+ if (/net::ERR_(?:CONNECTION_REFUSED|CONNECTION_TIMED_OUT|NAME_NOT_RESOLVED|ADDRESS_UNREACHABLE|INTERNET_DISCONNECTED|EMPTY_RESPONSE)/i.test(message)) {
1472
+ return "service_unavailable";
1473
+ }
1474
+ return "action_failed";
1475
+ }
1476
+ function messageFor(error) {
1477
+ const message = error instanceof Error ? error.message : "The browser action failed.";
1478
+ return sanitizeUrls(message).slice(0, 500);
1479
+ }
1480
+ function deviceContext(mode) {
1481
+ if (mode === "mobile") {
1482
+ return {
1483
+ name: "iPhone 13",
1484
+ contextOptions: {
1485
+ viewport: { width: 390, height: 844 },
1486
+ deviceScaleFactor: 3,
1487
+ isMobile: true,
1488
+ hasTouch: true,
1489
+ userAgent: IPHONE_13_USER_AGENT
1490
+ }
1491
+ };
1492
+ }
1493
+ return {
1494
+ name: "Desktop",
1495
+ contextOptions: {
1496
+ viewport: { width: 1440, height: 900 },
1497
+ deviceScaleFactor: 1,
1498
+ isMobile: false,
1499
+ hasTouch: false
1500
+ }
1501
+ };
1502
+ }
1503
+ async function launchSessionBrowser(headless, deadlineAt, onServerReady, onBrowserClosed) {
1504
+ const browserServer = await chromium2.launchServer({
1505
+ headless,
1506
+ timeout: Math.max(1, deadlineAt - Date.now())
1507
+ });
1508
+ let forceClosePromise;
1509
+ const forceClose = () => {
1510
+ forceClosePromise ??= browserServer.kill();
1511
+ return forceClosePromise;
1512
+ };
1513
+ onServerReady(forceClose);
1514
+ try {
1515
+ const connectTimeoutMs = Math.max(1, deadlineAt - Date.now());
1516
+ if (deadlineAt <= Date.now()) throw new Error("The browser action exceeded its deadline.");
1517
+ const browser = await chromium2.connect(browserServer.wsEndpoint(), { timeout: connectTimeoutMs });
1518
+ browser.once("disconnected", onBrowserClosed);
1519
+ let closePromise;
1520
+ return {
1521
+ newPage: async (options) => {
1522
+ const page = await browser.newPage(options);
1523
+ page.once("close", onBrowserClosed);
1524
+ return page;
1525
+ },
1526
+ close: () => {
1527
+ closePromise ??= (async () => {
1528
+ let closeError;
1529
+ try {
1530
+ await browser.close();
1531
+ } catch (error) {
1532
+ closeError = error;
1533
+ }
1534
+ try {
1535
+ await browserServer.close();
1536
+ } catch (error) {
1537
+ closeError ??= error;
1538
+ }
1539
+ if (closeError) throw closeError;
1540
+ })();
1541
+ return closePromise;
1542
+ },
1543
+ forceClose
1544
+ };
1545
+ } catch (error) {
1546
+ await forceClose().catch(() => void 0);
1547
+ throw error;
1548
+ }
1549
+ }
1550
+ var pendingDataRequests = /* @__PURE__ */ new WeakMap();
1551
+ var DATA_REQUEST_WAIT_MS = 1e4;
1552
+ function trackDataRequests(page, context) {
1553
+ const started = /* @__PURE__ */ new Map();
1554
+ context.on("request", (request) => {
1555
+ if (request.resourceType() === "fetch" || request.resourceType() === "xhr") started.set(request, Date.now());
1556
+ });
1557
+ const done = (request) => {
1558
+ started.delete(request);
1559
+ };
1560
+ context.on("requestfinished", done);
1561
+ context.on("requestfailed", done);
1562
+ pendingDataRequests.set(page, () => {
1563
+ let count = 0;
1564
+ for (const [request, at] of started) {
1565
+ if (Date.now() - at < DATA_REQUEST_WAIT_MS) count++;
1566
+ else started.delete(request);
1567
+ }
1568
+ return count;
1569
+ });
1570
+ }
1571
+ async function waitForRenderedPage(page) {
1572
+ if (!page.evaluate) throw new Error("The browser page does not support inspection.");
1573
+ const pending = pendingDataRequests.get(page) ?? (() => 0);
1574
+ let settled = false;
1575
+ while (true) {
1576
+ const ready = await page.evaluate(() => {
1577
+ const visible = (element) => {
1578
+ const rect = element.getBoundingClientRect();
1579
+ const style = getComputedStyle(element);
1580
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
1581
+ };
1582
+ const loading = Array.from(document.querySelectorAll('[data-session-loading], [aria-busy="true"]')).some(visible);
1583
+ const animating = document.getAnimations().some(
1584
+ (animation) => (animation.pending || animation.playState === "running") && Number.isFinite(Number(animation.effect?.getComputedTiming().endTime))
1585
+ );
1586
+ const imagesPending = Array.from(document.images).some((image) => visible(image) && !image.complete);
1587
+ const hasContent = !!document.body?.innerText.trim() || Array.from(
1588
+ document.querySelectorAll('button, a[href], input:not([type="hidden"]), select, textarea, canvas, svg, img, video, iframe')
1589
+ ).some(visible);
1590
+ return document.readyState === "complete" && document.fonts.status === "loaded" && hasContent && !loading && !animating && !imagesPending;
1591
+ });
1592
+ const idle = ready && pending() === 0;
1593
+ if (idle && settled) return;
1594
+ settled = idle;
1595
+ await wait(50);
1596
+ }
1597
+ }
1598
+ async function inspectPage(page, mode, crawler) {
1599
+ await waitForRenderedPage(page);
1600
+ if (!page.evaluate) throw new Error("The browser page does not support inspection.");
1601
+ const result = await page.evaluate(() => {
1602
+ const visible = (element) => {
1603
+ const rect = element.getBoundingClientRect();
1604
+ const style = getComputedStyle(element);
1605
+ return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
1606
+ };
1607
+ const hasVisible = (selector) => Array.from(document.querySelectorAll(selector)).some(visible);
1608
+ const operatorLogin2 = hasVisible('[data-operator-login-gate="true"]');
1609
+ const zerodhaLogin = hasVisible('[data-zerodha-login-page="true"]');
1610
+ const operatorPage = hasVisible('[data-operator-page="true"]');
1611
+ const restoring = hasVisible('[data-zerodha-restoration="pending"]');
1612
+ let authentication = { status: "unknown" };
1613
+ if (Number(operatorLogin2) + Number(zerodhaLogin) + Number(operatorPage) + Number(restoring) === 1 && !hasVisible('[data-session-loading], [aria-busy="true"]')) {
1614
+ if (operatorLogin2) authentication = { status: "operator_login_required", nextAction: "operator-login" };
1615
+ if (operatorPage) authentication = { status: "authenticated" };
1616
+ if (restoring) authentication = { status: "zerodha_session_restoring" };
1617
+ if (zerodhaLogin) {
1618
+ authentication = { status: "zerodha_login_required" };
1619
+ const attempt = document.querySelector('[data-zerodha-login-page="true"] [data-operator-login-attempt]');
1620
+ if (!attempt) authentication = { status: "zerodha_login_required", nextAction: "zerodha-login" };
1621
+ else if (visible(attempt) && attempt.getAttribute("data-operator-login-status") === "awaiting_otp" && attempt.getAttribute("data-operator-login-submitting-otp") !== "true") {
1622
+ const attemptId = attempt.getAttribute("data-operator-login-attempt-id");
1623
+ if (attemptId) authentication = { status: "zerodha_login_required", nextAction: "zerodha-otp", attemptId };
1624
+ }
1625
+ }
1626
+ }
1627
+ const controls2 = Array.from(
1628
+ document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
1629
+ ).filter(visible).slice(0, 100).map((element, index) => {
1630
+ const labels = element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement || element instanceof HTMLSelectElement ? Array.from(element.labels ?? []).map((item) => item.innerText).join(" ") : "";
1631
+ const label = element.getAttribute("aria-label") || element.getAttribute("title") || labels || (element instanceof HTMLInputElement || element instanceof HTMLTextAreaElement ? element.placeholder : "") || (element instanceof HTMLSelectElement ? element.selectedOptions[0]?.innerText : "") || element.textContent || "";
1632
+ const kind = element.getAttribute("role") || element.tagName.toLowerCase();
1633
+ return {
1634
+ reference: `control-${index + 1}`,
1635
+ kind,
1636
+ label: label.replace(/\s+/g, " ").trim().slice(0, 160)
1637
+ };
1638
+ });
1639
+ return {
1640
+ authentication,
1641
+ title: document.title,
1642
+ text: document.body?.innerText ?? "",
1643
+ controls: controls2,
1644
+ viewport: { width: window.innerWidth, height: window.innerHeight },
1645
+ deviceScaleFactor: window.devicePixelRatio,
1646
+ hasTouch: navigator.maxTouchPoints > 0,
1647
+ userAgent: navigator.userAgent
1648
+ };
1649
+ });
1650
+ const controls = crawler ? await crawler.controls().inspect() : result.controls;
1651
+ const redact = (value) => sanitizeUrls(crawler?.controls().redact(value) ?? value);
1652
+ const viewport = page.viewportSize?.() ?? result.viewport;
1653
+ return {
1654
+ authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
1655
+ url: safeUrl(redact(page.url())),
1656
+ visibleState: {
1657
+ title: redact(result.title).slice(0, 300),
1658
+ text: redact(result.text).slice(0, 4e3),
1659
+ controls: controls.map((control) => ({ ...control, label: redact(control.label) }))
1660
+ },
1661
+ device: {
1662
+ name: deviceContext(mode).name,
1663
+ mode,
1664
+ viewport,
1665
+ deviceScaleFactor: result.deviceScaleFactor,
1666
+ hasTouch: result.hasTouch,
1667
+ userAgent: sanitizeUrls(result.userAgent).slice(0, 300)
1668
+ }
1669
+ };
1670
+ }
1671
+ var ACCESS_TOKEN_SCREENSHOT_MASK = '[data-lmt-sensitive="zerodha-access-token"]';
1672
+ async function screenshotMaskOptions(page) {
1673
+ const playwrightPage = page;
1674
+ const locator = playwrightPage.locator(ACCESS_TOKEN_SCREENSHOT_MASK);
1675
+ if (!await locator.count()) return {};
1676
+ return { mask: [locator], maskColor: "#000000" };
1677
+ }
1678
+ async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, onTimeout, crawler) {
1679
+ if (!page) return { outcome: "unavailable", reason: "No browser page was available." };
1680
+ if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
1681
+ const directory = artifactsDirectory(sessionId);
1682
+ const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
1683
+ const screenshotPath = path.resolve(directory, filename);
1684
+ if (timeoutMs < 1) {
1685
+ return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
1686
+ }
1687
+ try {
1688
+ await mkdir(directory, { recursive: true, mode: 448 });
1689
+ await chmod(directory, 448);
1690
+ await withDeadline(
1691
+ (async () => {
1692
+ await waitForRenderedPage(page);
1693
+ const maskOptions = crawler ? { mask: await crawler.controls().screenshotMasks(), maskColor: "#000000" } : await screenshotMaskOptions(page);
1694
+ const options = {
1695
+ path: screenshotPath,
1696
+ fullPage,
1697
+ animations: "disabled",
1698
+ timeout: timeoutMs,
1699
+ ...maskOptions
1700
+ };
1701
+ return fullPage ? captureFullContent(page, options) : page.screenshot(options);
1702
+ })(),
1703
+ timeoutMs,
1704
+ onTimeout
1705
+ );
1706
+ await chmod(screenshotPath, 384);
1707
+ return { outcome: "captured", path: screenshotPath, fullPage };
1708
+ } catch (error) {
1709
+ await unlink(screenshotPath).catch(() => void 0);
1710
+ return { outcome: "failed", error: messageFor(error) };
1711
+ }
1712
+ }
1713
+ function remainingMs(deadlineAt) {
1714
+ return Math.max(0, deadlineAt - Date.now());
1715
+ }
1716
+ function withDeadline(promise, timeoutMs, onTimeout) {
1717
+ return new Promise((resolve, reject) => {
1718
+ let settled = false;
1719
+ const finish = (callback) => {
1720
+ if (settled) return;
1721
+ settled = true;
1722
+ clearTimeout(timeout);
1723
+ callback();
1724
+ };
1725
+ const timeout = setTimeout(() => {
1726
+ onTimeout?.();
1727
+ finish(() => reject(new Error("The browser action exceeded its deadline.")));
1728
+ }, Math.max(1, timeoutMs));
1729
+ promise.then(
1730
+ (value) => finish(() => resolve(value)),
1731
+ (error) => finish(() => reject(error))
1732
+ );
1733
+ });
1734
+ }
1735
+ var LOGIN_ACTIONS = /* @__PURE__ */ new Set(["operator-login", "operator-logout", "zerodha-login", "zerodha-otp", "zerodha-cancel", "zerodha-logout"]);
1736
+ var FRESH_SESSION_REQUIRED = "Browser login commands run only in a fresh session: use `lmt web start --fresh` to test the login screens, or `lmt` and `lmt logout` to log the Controller Session in or out.";
1737
+ async function runSessionOwner(options) {
1738
+ await ensureSessionDirectory();
1739
+ const socketPath = socketPathFor(options.sessionId);
1740
+ const device = deviceContext(options.mode);
1741
+ const controllerTarget = { environment: options.environment ?? "local", origin: options.origin, operatorId: "OPERATOR_1" };
1742
+ const controllerStore = options.controller ? new CliProfileStore() : void 0;
1743
+ let controllerCookies;
1744
+ let checkpointQueue = Promise.resolve();
1745
+ function checkpoint() {
1746
+ if (!controllerStore || !controllerCookies || !page) return Promise.resolve();
1747
+ const context = page.context?.();
1748
+ if (!context) return Promise.resolve();
1749
+ const save = async () => {
1750
+ const cookies = await context.cookies(options.origin);
1751
+ const value = (name) => {
1752
+ const cookie = cookies.find((candidate) => candidate.name === name);
1753
+ return cookie ? `${cookie.name}=${cookie.value}` : void 0;
1754
+ };
1755
+ const operatorSessionCookie = value("lmt_operator_session");
1756
+ if (!operatorSessionCookie || operatorSessionCookie === controllerCookies.operatorSessionCookie) return;
1757
+ const clientIdentityCookie = value("lmt_client_id") ?? controllerCookies.clientIdentityCookie;
1758
+ const next = { operatorSessionCookie, ...clientIdentityCookie ? { clientIdentityCookie } : {} };
1759
+ await controllerStore.saveSession(CONTROLLER_SESSION, controllerTarget, next);
1760
+ controllerCookies = next;
1761
+ };
1762
+ checkpointQueue = checkpointQueue.then(save, save).catch(() => void 0);
1763
+ return checkpointQueue;
1764
+ }
1765
+ let crawler;
1766
+ let page;
1767
+ let startupError;
1768
+ let startComplete = false;
1769
+ let closing = false;
1770
+ let abortOnShutdown = false;
1771
+ let operationQueue = Promise.resolve();
1772
+ let shutdownPromise;
1773
+ let initializationPromise;
1774
+ let startupLease;
1775
+ let crawlerClosePromise;
1776
+ let expirationCleanup;
1777
+ let forceClosePendingBrowser;
1778
+ let pendingBrowserForceClosePromise;
1779
+ const sockets = /* @__PURE__ */ new Set();
1780
+ function closeCrawler() {
1781
+ if (!crawler) return Promise.resolve();
1782
+ crawlerClosePromise ??= crawler.close();
1783
+ return crawlerClosePromise;
1784
+ }
1785
+ function forceCloseBrowser() {
1786
+ if (crawler?.forceClose) return crawler.forceClose();
1787
+ if (!forceClosePendingBrowser) return Promise.resolve();
1788
+ pendingBrowserForceClosePromise ??= forceClosePendingBrowser();
1789
+ return pendingBrowserForceClosePromise;
1790
+ }
1791
+ function expireSession() {
1792
+ closing = true;
1793
+ abortOnShutdown = true;
1794
+ expirationCleanup ??= (async () => {
1795
+ if (initializationPromise) {
1796
+ await Promise.race([
1797
+ initializationPromise,
1798
+ wait(OWNER_CLEANUP_GRACE_MS)
1799
+ ]).catch(() => void 0);
1800
+ }
1801
+ if (crawler) await closeCrawler();
1802
+ else await forceCloseBrowser();
1803
+ })();
1804
+ void expirationCleanup.catch(() => void 0);
1805
+ }
1806
+ const server = net.createServer((socket) => {
1807
+ sockets.add(socket);
1808
+ socket.once("close", () => sockets.delete(socket));
1809
+ let payload = "";
1810
+ let handled = false;
1811
+ socket.on("data", (chunk) => {
1812
+ if (handled) return;
1813
+ payload += chunk.toString("utf8");
1814
+ const newline = payload.indexOf("\n");
1815
+ if (newline < 0) return;
1816
+ handled = true;
1817
+ const line = payload.slice(0, newline);
1818
+ let request;
1819
+ try {
1820
+ request = JSON.parse(line);
1821
+ } catch {
1822
+ socket.end(`${JSON.stringify(failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId))}
1823
+ `);
1824
+ return;
1825
+ }
1826
+ const run = async () => {
1827
+ let response;
1828
+ try {
1829
+ response = await handleRequest(request);
1830
+ } catch (error) {
1831
+ response = failureResult(request.action, "action_failed", "The browser action could not complete safely. Inspect before retrying.", options.sessionId);
1832
+ }
1833
+ if (isAction(request.action) && isControlAction(request)) {
1834
+ response.audit = { outcome: "unavailable" };
1835
+ if (page?.evaluate && new URL(page.url()).origin === options.origin) {
1836
+ try {
1837
+ const recorded = await withDeadline(page.evaluate(async (event) => {
1838
+ const response2 = await fetch("/api/journey-records", {
1839
+ method: "POST",
1840
+ headers: { "content-type": "application/json" },
1841
+ body: JSON.stringify({ records: [{
1842
+ eventType: "journey_step",
1843
+ eventValue: `Web CLI ${event.action}`,
1844
+ session: event.session,
1845
+ eventMeta: {
1846
+ step: `Web CLI ${event.action}`,
1847
+ outcome: event.outcome === "success" ? "success" : "failure",
1848
+ ...event.outcome === "success" ? {} : { error_code: event.outcome === "uncertain" ? "action_uncertain" : "browser_action_failed" },
1849
+ web_visit: event.session,
1850
+ journey_id: event.session,
1851
+ client_kind: "cli",
1852
+ client_profile: event.profile,
1853
+ action_outcome: event.outcome,
1854
+ screenshot_outcome: event.screenshot
1855
+ }
1856
+ }] }),
1857
+ signal: AbortSignal.timeout(500)
1858
+ });
1859
+ return response2.status === 202;
1860
+ }, { action: request.action, session: options.sessionId, profile: `web-cli-${options.mode}`, outcome: response.outcome, screenshot: response.screenshot.outcome }), 750);
1861
+ response.audit = { outcome: recorded ? "recorded" : "failed" };
1862
+ } catch {
1863
+ response.audit = { outcome: "failed" };
1864
+ }
1865
+ }
1866
+ }
1867
+ socket.end(`${JSON.stringify({ ...response, origin: options.origin })}
1868
+ `);
1869
+ if (closing) socket.once("close", () => void shutdown());
1870
+ };
1871
+ operationQueue = operationQueue.then(run, run).catch(() => void 0);
1872
+ });
1873
+ });
1874
+ async function initialize(deadlineAt) {
1875
+ try {
1876
+ crawler = await createCrawler({
1877
+ baseUrl: options.origin,
1878
+ headless: options.headless,
1879
+ timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
1880
+ contextOptions: device.contextOptions,
1881
+ launch: () => {
1882
+ const onServerReady = (forceClose) => {
1883
+ forceClosePendingBrowser = forceClose;
1884
+ if (abortOnShutdown) void forceCloseBrowser().catch(() => void 0);
1885
+ };
1886
+ return options.launch ? options.launch(deadlineAt, onServerReady) : launchSessionBrowser(options.headless, deadlineAt, onServerReady, () => {
1887
+ if (closing) return;
1888
+ closing = true;
1889
+ abortOnShutdown = true;
1890
+ void shutdown();
1891
+ });
1892
+ }
1893
+ });
1894
+ page = crawler.getPage();
1895
+ const browserContext = page.context?.();
1896
+ if (browserContext) trackDataRequests(page, browserContext);
1897
+ if (browserContext?.route) await browserContext.route("**/*", async (route) => {
1898
+ const request = route.request();
1899
+ if (new URL(request.url()).origin !== options.origin) return route.continue();
1900
+ await route.continue({ headers: { ...request.headers(), "x-lmt-client-kind": BROWSER_CLIENT_KIND, "x-lmt-client-profile": `web-cli-${options.mode}` } });
1901
+ });
1902
+ if (controllerStore) {
1903
+ controllerCookies = await controllerStore.getSession(CONTROLLER_SESSION, controllerTarget);
1904
+ if (!controllerCookies?.operatorSessionCookie) throw new Error("The Controller Session is not authenticated. Run `lmt` in a terminal to log in.");
1905
+ await browserContext?.addCookies?.([controllerCookies.operatorSessionCookie, controllerCookies.clientIdentityCookie].filter((cookie) => !!cookie).map((cookie) => {
1906
+ const separator = cookie.indexOf("=");
1907
+ return { name: cookie.slice(0, separator), value: cookie.slice(separator + 1), url: options.origin, httpOnly: true, sameSite: "Lax" };
1908
+ }));
1909
+ browserContext?.on?.("requestfinished", (request) => {
1910
+ if (new URL(request.url()).origin !== options.origin) return;
1911
+ void request.response().then(async (response) => {
1912
+ const headers = await response?.allHeaders();
1913
+ if (headers?.["set-cookie"] && !closing) await checkpoint();
1914
+ }).catch(() => void 0);
1915
+ });
1916
+ }
1917
+ if (closing) {
1918
+ await closeCrawler();
1919
+ return;
1920
+ }
1921
+ await crawler.openHomePage();
1922
+ await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
1923
+ } catch (error) {
1924
+ startupError = error;
1925
+ }
1926
+ }
1927
+ async function handleRequest(request) {
1928
+ if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
1929
+ return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
1930
+ }
1931
+ if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start a new session.", options.sessionId);
1932
+ if (LOGIN_ACTIONS.has(request.action) && options.controller) {
1933
+ return failureResult(request.action, "fresh_session_required", FRESH_SESSION_REQUIRED, options.sessionId);
1934
+ }
1935
+ const deadlineAt = request.deadlineAt;
1936
+ if (remainingMs(deadlineAt) === 0) {
1937
+ return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
1938
+ }
1939
+ if (request.action === "start" && startComplete) {
1940
+ return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
1941
+ }
1942
+ if (request.action === "stop") {
1943
+ await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
1944
+ closing = true;
1945
+ abortOnShutdown = true;
1946
+ try {
1947
+ crawlerClosePromise = forceCloseBrowser();
1948
+ await withDeadline(crawlerClosePromise, remainingMs(deadlineAt), expireSession);
1949
+ return {
1950
+ version: RESULT_VERSION,
1951
+ sessionId: options.sessionId,
1952
+ action: "stop",
1953
+ outcome: "success",
1954
+ authentication: { status: "unknown" },
1955
+ url: null,
1956
+ visibleState: null,
1957
+ device: null,
1958
+ screenshot: { outcome: "unavailable", reason: "Stop terminates the browser without capturing a screenshot." }
1959
+ };
1960
+ } catch (error2) {
1961
+ return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
1962
+ }
1963
+ }
1964
+ if (!startComplete && request.action !== "start") {
1965
+ return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
1966
+ }
1967
+ if (!startComplete) {
1968
+ clearTimeout(startupLease);
1969
+ initializationPromise ??= initialize(deadlineAt);
1970
+ try {
1971
+ await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
1972
+ } catch (error2) {
1973
+ startupError = error2;
1974
+ }
1975
+ }
1976
+ if (request.action === "start") {
1977
+ startComplete = true;
1978
+ let inspectionError;
1979
+ const inspected2 = page && !startupError && remainingMs(deadlineAt) > 0 ? await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession).catch((error2) => {
1980
+ inspectionError = error2;
1981
+ return null;
1982
+ }) : null;
1983
+ const screenshot2 = startupError || inspectionError ? { outcome: "unavailable", reason: "The page did not finish loading and inspection." } : await captureScreenshot(
1984
+ page,
1985
+ options.sessionId,
1986
+ "start",
1987
+ false,
1988
+ remainingMs(deadlineAt),
1989
+ expireSession,
1990
+ crawler
1991
+ );
1992
+ const actionError2 = startupError ?? inspectionError;
1993
+ const result = {
1994
+ version: RESULT_VERSION,
1995
+ sessionId: options.sessionId,
1996
+ action: "start",
1997
+ outcome: actionError2 ? "failure" : "success",
1998
+ authentication: inspected2?.authentication ?? { status: "unknown" },
1999
+ url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
2000
+ visibleState: inspected2?.visibleState ?? null,
2001
+ device: inspected2?.device ?? null,
2002
+ screenshot: screenshot2,
2003
+ ...startupError ? {
2004
+ error: {
2005
+ code: failureCode(startupError),
2006
+ message: page ? failureCode(startupError) === "service_unavailable" ? "Could not load the configured Web URL. Confirm that the selected service is running and reachable." : failureCode(startupError) === "deadline_exceeded" ? "Opening the Home Page exceeded its deadline. Start a fresh session and retry." : messageFor(startupError) : failureCode(startupError) === "browser_unavailable" ? "Could not launch the browser. Install its Chromium runtime with `lmt web install-browser` and retry." : messageFor(startupError)
2007
+ }
2008
+ } : inspectionError ? {
2009
+ error: {
2010
+ code: failureCode(inspectionError),
2011
+ message: "The Home Page loaded but its visible state could not be inspected."
2012
+ }
2013
+ } : {}
2014
+ };
2015
+ if (startupError) {
2016
+ closing = true;
2017
+ }
2018
+ return result;
2019
+ }
2020
+ if (startupError || !crawler || !page) {
2021
+ return failureResult(request.action, "session_unavailable", "The browser session is no longer available. Start a new session.", options.sessionId);
2022
+ }
2023
+ let guidedError;
2024
+ let submitted = false;
2025
+ const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 2));
2026
+ const pendingActions = /* @__PURE__ */ new Set();
2027
+ const runAction = (operation, timeoutMs) => {
2028
+ pendingActions.add(operation);
2029
+ operation.then(() => pendingActions.delete(operation), () => pendingActions.delete(operation));
2030
+ return withDeadline(operation, timeoutMs);
2031
+ };
2032
+ const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
2033
+ page.setDefaultTimeout?.(actionTimeout());
2034
+ page.setDefaultNavigationTimeout?.(actionTimeout());
2035
+ let loginAttempt;
2036
+ const onSubmit = () => {
2037
+ submitted = true;
2038
+ };
2039
+ const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
2040
+ try {
2041
+ if (request.action === "operator-login") await runAction(operatorLogin({ page, environment: options.environment ?? "local", origin: options.origin, timeoutMs: actionTimeout(), onSubmit, registerSecrets: (...values) => crawler.controls().registerSecrets(...values) }), actionTimeout());
2042
+ if (request.action === "operator-logout") await runAction(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout());
2043
+ if (request.action === "zerodha-login") loginAttempt = await runAction(zerodha.startLoginAttempt(onSubmit), actionTimeout());
2044
+ if (request.action === "zerodha-cancel") await runAction(zerodha.cancelLoginAttempt(onSubmit), actionTimeout());
2045
+ if (request.action === "zerodha-otp") {
2046
+ if (!request.attemptId || !request.otp || !/^\d{6}$/.test(request.otp)) throw new OperatorActionError("invalid_otp", "Supply a six-digit OTP and the attempt ID returned by zerodha-login.");
2047
+ crawler.controls().registerSecrets(request.otp);
2048
+ const result = await runAction(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout());
2049
+ if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
2050
+ }
2051
+ if (request.action === "zerodha-logout") await runAction(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout());
2052
+ } catch (error2) {
2053
+ guidedError = error2;
2054
+ }
2055
+ let inspected = null;
2056
+ let controlResult;
2057
+ let actionError;
2058
+ try {
2059
+ if (isControlAction(request)) {
2060
+ const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
2061
+ controlResult = await runAction(crawler.controls().act(request, budget, () => {
2062
+ submitted = true;
2063
+ }), budget);
2064
+ }
2065
+ } catch (error2) {
2066
+ actionError = request.action === "click" && submitted && failureCode(error2) === "deadline_exceeded" ? new ControlError("action_uncertain", "The click may have been submitted but its completion was not confirmed. Inspect before deciding whether to retry.") : error2;
2067
+ if (request.action === "click" && failureCode(actionError) === "action_uncertain") {
2068
+ await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
2069
+ }
2070
+ }
2071
+ if (pendingActions.size || failureCode(actionError ?? guidedError) === "deadline_exceeded") {
2072
+ try {
2073
+ await withDeadline((async () => {
2074
+ await crawler.controls().stopLoading();
2075
+ await Promise.allSettled([...pendingActions]);
2076
+ await page.evaluate?.(() => document.readyState);
2077
+ })(), Math.min(500, Math.max(1, remainingMs(deadlineAt))), expireSession);
2078
+ } catch {
2079
+ expireSession();
2080
+ return { ...failureResult(request.action, submitted ? "action_uncertain" : "deadline_exceeded", "The browser could not safely recover from the action deadline. Start a new session; do not replay an uncertain action.", options.sessionId), outcome: submitted ? "uncertain" : "failure" };
2081
+ }
2082
+ }
2083
+ if (controllerStore) await withDeadline(checkpoint(), Math.max(1, remainingMs(deadlineAt))).catch(() => void 0);
2084
+ try {
2085
+ inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
2086
+ } catch (error2) {
2087
+ actionError ??= error2;
2088
+ }
2089
+ const screenshot = await captureScreenshot(
2090
+ page,
2091
+ options.sessionId,
2092
+ request.action,
2093
+ request.fullPage === true,
2094
+ remainingMs(deadlineAt),
2095
+ expireSession,
2096
+ crawler
2097
+ );
2098
+ let closeError;
2099
+ if (request.action === "close") {
2100
+ closing = true;
2101
+ try {
2102
+ await withDeadline(closeCrawler(), remainingMs(deadlineAt), expireSession);
2103
+ } catch (error2) {
2104
+ closeError = error2;
2105
+ if (!abortOnShutdown) {
2106
+ await crawler.forceClose?.().catch(() => void 0);
2107
+ }
2108
+ }
2109
+ }
2110
+ const error = guidedError ?? actionError ?? closeError;
2111
+ return {
2112
+ version: RESULT_VERSION,
2113
+ sessionId: options.sessionId,
2114
+ action: request.action,
2115
+ outcome: error instanceof ControlError && error.code === "action_uncertain" ? "uncertain" : error ? submitted && !(error instanceof OperatorActionError) && !(error instanceof LoginAttemptError) ? "uncertain" : "failure" : "success",
2116
+ authentication: inspected?.authentication ?? { status: "unknown" },
2117
+ ...!actionError ? controlResult : {},
2118
+ ...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
2119
+ url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
2120
+ visibleState: inspected?.visibleState ?? null,
2121
+ device: inspected?.device ?? null,
2122
+ screenshot,
2123
+ ...error ? { error: { code: error instanceof OperatorActionError || error instanceof LoginAttemptError ? error.code : failureCode(error), message: crawler.controls().redact(error instanceof OperatorActionError || error instanceof LoginAttemptError ? error.message : messageFor(error)) } } : {}
2124
+ };
2125
+ }
2126
+ function shutdown() {
2127
+ if (!closing) return Promise.resolve();
2128
+ if (shutdownPromise) return shutdownPromise;
2129
+ clearTimeout(startupLease);
2130
+ shutdownPromise = (async () => {
2131
+ for (const socket of sockets) socket.destroy();
2132
+ await unlink(socketPath).catch(() => void 0);
2133
+ server.close();
2134
+ if (abortOnShutdown) {
2135
+ const cleanup = crawler ? closeCrawler() : forceClosePendingBrowser ? forceCloseBrowser() : expirationCleanup ?? Promise.resolve();
2136
+ let cleanupFinished = false;
2137
+ let cleanupFailed = false;
2138
+ const observedCleanup = cleanup.then(
2139
+ () => {
2140
+ cleanupFinished = true;
2141
+ },
2142
+ () => {
2143
+ cleanupFinished = true;
2144
+ cleanupFailed = true;
2145
+ }
2146
+ );
2147
+ await Promise.race([observedCleanup, wait(OWNER_CLEANUP_GRACE_MS)]);
2148
+ if (!cleanupFinished || cleanupFailed) {
2149
+ await forceCloseBrowser().catch(() => void 0);
2150
+ }
2151
+ (options.exit ?? process.exit)(0);
2152
+ return;
2153
+ }
2154
+ if (crawler) {
2155
+ try {
2156
+ await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
2157
+ await closeCrawler();
2158
+ } catch {
2159
+ await crawler.forceClose?.().catch(() => void 0);
2160
+ }
2161
+ } else if (forceClosePendingBrowser) {
2162
+ await forceCloseBrowser().catch(() => void 0);
2163
+ }
2164
+ await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
2165
+ process.removeListener("SIGTERM", onSignal);
2166
+ process.removeListener("SIGINT", onSignal);
2167
+ })();
2168
+ return shutdownPromise;
2169
+ }
2170
+ await new Promise((resolve, reject) => {
2171
+ server.once("error", reject);
2172
+ server.listen(socketPath, () => {
2173
+ server.removeListener("error", reject);
2174
+ void chmod(socketPath, 384).then(resolve, reject);
2175
+ });
2176
+ });
2177
+ startupLease = setTimeout(() => {
2178
+ if (initializationPromise) return;
2179
+ expireSession();
2180
+ void shutdown();
2181
+ }, options.timeoutMs);
2182
+ const onSignal = () => {
2183
+ closing = true;
2184
+ void shutdown();
2185
+ };
2186
+ process.once("SIGTERM", onSignal);
2187
+ process.once("SIGINT", onSignal);
2188
+ }
2189
+ async function wait(milliseconds) {
2190
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
2191
+ }
2192
+ function assertOwnedSocket(socketPath) {
2193
+ return lstat(socketPath).then((entry) => {
2194
+ const uid = process.getuid?.();
2195
+ if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
2196
+ throw new Error("The lmt web session socket is not owned by the invoking user.");
2197
+ }
2198
+ });
2199
+ }
2200
+ async function requestSession(request, options = {}) {
2201
+ if (!isSessionId(request.sessionId)) {
2202
+ return failureResult(request.action, "invalid_session_id", "A valid session ID is required.", request.sessionId || null);
2203
+ }
2204
+ const socketPath = socketPathFor(request.sessionId);
2205
+ const deadlineAt = Date.now() + request.timeoutMs;
2206
+ const wireRequest = { ...request, deadlineAt };
2207
+ if (options.waitForSocket) {
2208
+ let ready = false;
2209
+ while (Date.now() < deadlineAt) {
2210
+ try {
2211
+ await assertOwnedSocket(socketPath);
2212
+ ready = true;
2213
+ break;
2214
+ } catch {
2215
+ if (options.ownerAlive && !options.ownerAlive()) {
2216
+ return failureResult(request.action, "browser_start_failed", "The browser session owner exited before it became ready.", request.sessionId);
2217
+ }
2218
+ await wait(40);
2219
+ }
2220
+ }
2221
+ if (!ready) {
2222
+ return failureResult(request.action, "deadline_exceeded", "The browser session did not become ready before its deadline.", request.sessionId);
2223
+ }
2224
+ }
2225
+ try {
2226
+ await assertOwnedSocket(socketPath);
2227
+ } catch {
2228
+ return failureResult(request.action, "session_not_found", "The browser session is not live. Start a new session.", request.sessionId);
2229
+ }
2230
+ return new Promise((resolve) => {
2231
+ const socket = net.createConnection(socketPath);
2232
+ let received = "";
2233
+ let settled = false;
2234
+ const finish = (result) => {
2235
+ if (settled) return;
2236
+ settled = true;
2237
+ clearTimeout(timer);
2238
+ socket.destroy();
2239
+ resolve(result);
2240
+ };
2241
+ const timer = setTimeout(() => {
2242
+ finish(failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", request.sessionId));
2243
+ }, Math.max(1, deadlineAt + OWNER_RESPONSE_GRACE_MS - Date.now()));
2244
+ socket.once("connect", () => socket.write(`${JSON.stringify(wireRequest)}
2245
+ `));
2246
+ socket.on("data", (chunk) => {
2247
+ received += chunk.toString("utf8");
2248
+ const newline = received.indexOf("\n");
2249
+ if (newline < 0) return;
2250
+ try {
2251
+ finish(JSON.parse(received.slice(0, newline)));
2252
+ } catch {
2253
+ finish(failureResult(request.action, "invalid_response", "The browser session returned an invalid result.", request.sessionId));
2254
+ }
2255
+ });
2256
+ socket.once("error", () => {
2257
+ finish(failureResult(request.action, "session_not_found", "The browser session is not live. Start a new session.", request.sessionId));
2258
+ });
2259
+ socket.once("close", () => {
2260
+ if (!settled) {
2261
+ finish(failureResult(request.action, "session_not_found", "The browser session closed before returning a result.", request.sessionId));
2262
+ }
2263
+ });
2264
+ });
2265
+ }
2266
+
2267
+ // src/web/owners.ts
2268
+ var REGISTRY = path2.join(SESSION_DIRECTORY, "owners.json");
2269
+ var LOCK = `${REGISTRY}.lock`;
2270
+ var OwnerRegistryError = class extends Error {
2271
+ constructor() {
2272
+ super(`Could not access the live browser owner registry ${REGISTRY}. Check its ownership and permissions, or remove it.`);
2273
+ }
2274
+ };
2275
+ function sameKey(record, key) {
2276
+ return record.environment === key.environment && record.origin === key.origin && record.mode === key.mode;
2277
+ }
2278
+ function isOwnerRecord(value) {
2279
+ const record = value;
2280
+ return !!record && typeof record === "object" && (record.environment === "local" || record.environment === "dev") && typeof record.origin === "string" && (record.mode === "mobile" || record.mode === "desktop") && typeof record.sessionId === "string" && /^[a-f0-9]{32}$/.test(record.sessionId) && Number.isSafeInteger(record.pid) && record.pid > 0 && typeof record.headless === "boolean" && typeof record.socket === "string" && typeof record.processStartedAt === "string";
2281
+ }
2282
+ function processStartedAt(pid) {
2283
+ try {
2284
+ const startedAt = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
2285
+ encoding: "utf8",
2286
+ timeout: 1e3,
2287
+ stdio: ["ignore", "pipe", "ignore"],
2288
+ env: { ...process.env, LC_ALL: "C", TZ: "UTC" }
2289
+ }).trim();
2290
+ return startedAt || void 0;
2291
+ } catch {
2292
+ return void 0;
2293
+ }
2294
+ }
2295
+ function processExists(pid) {
2296
+ try {
2297
+ process.kill(pid, 0);
2298
+ } catch (error) {
2299
+ if (error.code === "ESRCH") return false;
2300
+ }
2301
+ return true;
2302
+ }
2303
+ function alive(record) {
2304
+ return processExists(record.pid) && processStartedAt(record.pid) === record.processStartedAt;
2305
+ }
2306
+ async function assertPrivateFile(filename) {
2307
+ const entry = await lstat2(filename);
2308
+ if (!entry.isFile() || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 511) !== 384) {
2309
+ throw new OwnerRegistryError();
2310
+ }
2311
+ }
2312
+ async function readRecords() {
2313
+ let raw;
2314
+ try {
2315
+ await assertPrivateFile(REGISTRY);
2316
+ raw = await readFile(REGISTRY, "utf8");
2317
+ } catch (error) {
2318
+ if (error.code === "ENOENT") return [];
2319
+ throw new OwnerRegistryError();
2320
+ }
2321
+ try {
2322
+ const parsed = JSON.parse(raw);
2323
+ if (parsed.version === 1 && Array.isArray(parsed.owners) && parsed.owners.every(isOwnerRecord)) return parsed.owners;
2324
+ } catch {
2325
+ }
2326
+ throw new OwnerRegistryError();
2327
+ }
2328
+ async function writeRecords(records) {
2329
+ const temporary = `${REGISTRY}.${process.pid}.${randomUUID()}.tmp`;
2330
+ try {
2331
+ const handle = await open(temporary, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 384);
2332
+ try {
2333
+ await handle.writeFile(`${JSON.stringify({ version: 1, owners: records }, null, 2)}
2334
+ `);
2335
+ await handle.sync();
2336
+ } finally {
2337
+ await handle.close();
2338
+ }
2339
+ await rename(temporary, REGISTRY);
2340
+ } catch {
2341
+ await unlink2(temporary).catch(() => void 0);
2342
+ throw new OwnerRegistryError();
2343
+ }
2344
+ }
2345
+ async function acquireLock(deadlineAt) {
2346
+ while (true) {
2347
+ try {
2348
+ const handle = await open(LOCK, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | constants.O_NOFOLLOW, 384);
2349
+ try {
2350
+ await handle.writeFile(`${process.pid}
2351
+ `);
2352
+ } finally {
2353
+ await handle.close();
2354
+ }
2355
+ return;
2356
+ } catch (error) {
2357
+ if (error.code !== "EEXIST") throw new OwnerRegistryError();
2358
+ }
2359
+ const holder = Number((await readFile(LOCK, "utf8").catch(() => "")).trim());
2360
+ if (Number.isSafeInteger(holder) && holder > 0 && !processExists(holder)) {
2361
+ await unlink2(LOCK).catch(() => void 0);
2362
+ continue;
2363
+ }
2364
+ if (Date.now() >= deadlineAt) throw new OwnerRegistryError();
2365
+ await new Promise((resolve) => setTimeout(resolve, 20));
2366
+ }
2367
+ }
2368
+ async function listLiveOwners() {
2369
+ return (await readRecords()).filter(alive).map(({ environment, origin, mode, sessionId, pid, headless }) => ({ environment, origin, mode, sessionId, pid, headless }));
2370
+ }
2371
+ async function reserveOwner(key, deadlineAt, action) {
2372
+ await acquireLock(deadlineAt);
2373
+ try {
2374
+ const records = await readRecords();
2375
+ const live = records.filter(alive);
2376
+ let changed = live.length !== records.length;
2377
+ const result = action({
2378
+ owner() {
2379
+ const record = live.find((candidate) => sameKey(candidate, key));
2380
+ return record ? { sessionId: record.sessionId, pid: record.pid, headless: record.headless } : void 0;
2381
+ },
2382
+ setOwner(owner) {
2383
+ const startedAt = processStartedAt(owner.pid);
2384
+ if (!startedAt) throw new OwnerRegistryError();
2385
+ const index = live.findIndex((candidate) => sameKey(candidate, key));
2386
+ if (index >= 0) live.splice(index, 1);
2387
+ live.push({ ...key, ...owner, socket: socketPathFor(owner.sessionId), processStartedAt: startedAt });
2388
+ changed = true;
2389
+ }
2390
+ });
2391
+ if (changed) await writeRecords(live);
2392
+ return result;
2393
+ } finally {
2394
+ await unlink2(LOCK).catch(() => void 0);
2395
+ }
2396
+ }
2397
+
2398
+ // src/web/start.ts
2399
+ import { spawn } from "node:child_process";
2400
+ import path3 from "node:path";
2401
+ import { fileURLToPath } from "node:url";
2402
+
2403
+ // src/web/config.ts
2404
+ import { readFileSync } from "node:fs";
2405
+ function resolveWebTarget(environmentValue, workingDirectory = process.cwd()) {
2406
+ const environment = environmentValue?.trim();
2407
+ if (environment !== "local" && environment !== "dev") {
2408
+ if (environment === "prod") {
2409
+ throw new Error("lmt web supports local or dev targets only; prod is not permitted.");
2410
+ }
2411
+ throw new Error("Target must explicitly select --env local or --env dev; prod is not permitted.");
2412
+ }
2413
+ let config;
2414
+ try {
2415
+ config = JSON.parse(readFileSync(DEFAULT_CONFIG_PATH, "utf8"));
2416
+ } catch {
2417
+ throw new Error("Could not read the deployment configuration lmt.config.json.");
2418
+ }
2419
+ const selected = config.environments?.[environment];
2420
+ if (!selected) {
2421
+ throw new Error(`lmt.config.json does not contain the ${environment} environment.`);
2422
+ }
2423
+ return { environment, origin: resolveTargetOrigin(environment, selected.origin, workingDirectory) };
2424
+ }
2425
+
2426
+ // src/web/start.ts
2427
+ function invalidTarget(error) {
2428
+ if (error instanceof LocalConfigError) return new InvocationError(error.message, error.code);
2429
+ return new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
2430
+ }
2431
+ async function controllerSessionProblem(target, deadlineAt) {
2432
+ const timeoutMs = Math.max(1, deadlineAt - Date.now());
2433
+ const { report, exitCode } = await reportSessionStatus({
2434
+ target,
2435
+ owner: CONTROLLER_SESSION,
2436
+ store: new CliProfileStore(),
2437
+ timeoutMs,
2438
+ signal: AbortSignal.timeout(timeoutMs)
2439
+ });
2440
+ if (exitCode === 0) return void 0;
2441
+ const controllerSession = { smsCredential: report.smsCredential, operatorSession: report.operatorSession, zerodha: report.zerodha };
2442
+ if (report.outcome === "blocked") {
2443
+ return {
2444
+ ...failureResult("start", "controller_session_unauthenticated", `The ${target.environment} Controller Session is not authenticated, so no browser was opened.`),
2445
+ outcome: "blocked",
2446
+ origin: target.origin,
2447
+ controllerSession,
2448
+ ...report.nextStep ? { nextStep: report.nextStep } : {}
2449
+ };
2450
+ }
2451
+ return { ...failureResult("start", report.error?.code ?? "action_failed", report.error?.message ?? "Could not check the Controller Session."), origin: target.origin, controllerSession };
2452
+ }
2453
+ async function startSession(command) {
2454
+ const deadlineAt = Date.now() + command.timeoutMs;
2455
+ const resolved = (resolve) => {
2456
+ try {
2457
+ return resolve();
2458
+ } catch (error) {
2459
+ throw invalidTarget(error);
2460
+ }
2461
+ };
2462
+ let target;
2463
+ if (command.fresh) target = resolved(() => resolveWebTarget(command.environment));
2464
+ else {
2465
+ const controllerTarget = resolved(() => resolveCliTarget({ LMT_ENV: command.environment }));
2466
+ const problem = await controllerSessionProblem(controllerTarget, deadlineAt);
2467
+ if (problem) return { result: problem, startedOwner: false };
2468
+ target = controllerTarget;
2469
+ }
2470
+ await ensureSessionDirectory();
2471
+ const key = { environment: target.environment, origin: target.origin, mode: command.mode };
2472
+ let spawned;
2473
+ let spawnedSessionId;
2474
+ try {
2475
+ const spawnOwner = () => {
2476
+ const sessionId = createSessionId();
2477
+ const scriptPath = path3.resolve(command.ownerScript ?? (process.argv[1] || fileURLToPath(import.meta.url)));
2478
+ const child = spawn(process.execPath, [
2479
+ scriptPath,
2480
+ "web",
2481
+ "__owner",
2482
+ sessionId,
2483
+ target.origin,
2484
+ command.mode,
2485
+ command.headless ? "headless" : "headed",
2486
+ String(command.timeoutMs),
2487
+ target.environment,
2488
+ command.fresh ? "fresh" : "controller"
2489
+ ], { detached: true, stdio: "ignore" });
2490
+ spawned = child;
2491
+ spawnedSessionId = sessionId;
2492
+ child.on("error", () => void 0);
2493
+ child.unref();
2494
+ if (!child.pid) throw new Error("Could not start the browser owner.");
2495
+ return { sessionId, pid: child.pid, headless: command.headless };
2496
+ };
2497
+ const selectOwner = () => command.fresh ? spawnOwner() : reserveOwner(key, deadlineAt, (registry) => {
2498
+ const existing = registry.owner();
2499
+ if (existing) {
2500
+ if (existing.headless !== command.headless) throw new InvocationError("The session is already live with different visibility. Close it before changing --headed, or use --fresh.");
2501
+ return existing;
2502
+ }
2503
+ const owner = spawnOwner();
2504
+ registry.setOwner(owner);
2505
+ return owner;
2506
+ });
2507
+ while (Date.now() < deadlineAt) {
2508
+ const owner = await selectOwner();
2509
+ const result = await requestSession({ sessionId: owner.sessionId, action: "start", timeoutMs: Math.max(1, deadlineAt - Date.now()) }, {
2510
+ waitForSocket: true,
2511
+ ownerAlive: () => processExists(owner.pid)
2512
+ });
2513
+ if (command.fresh || !["session_not_found", "session_unavailable", "browser_start_failed"].includes(result.error?.code ?? "")) {
2514
+ return { result: { ...result, origin: target.origin }, startedOwner: spawnedSessionId !== void 0 && result.sessionId === spawnedSessionId };
2515
+ }
2516
+ await new Promise((resolve) => setTimeout(resolve, 40));
2517
+ }
2518
+ return { result: { ...failureResult("start", "deadline_exceeded", "The browser owner did not become available before the deadline."), origin: target.origin }, startedOwner: false };
2519
+ } catch (error) {
2520
+ if (spawned?.pid) {
2521
+ try {
2522
+ process.kill(spawned.pid, "SIGTERM");
2523
+ } catch {
2524
+ }
2525
+ }
2526
+ throw error;
2527
+ }
2528
+ }
2529
+
2530
+ // src/web/index.ts
2531
+ var DEFAULT_TIMEOUT_MS2 = 3e4;
2532
+ var MAX_TIMEOUT_MS = 10 * 6e4;
2533
+ var HELP = `lmt web \u2014 browser interaction with Live Market Terminal Web
2534
+
2535
+ Usage:
2536
+ lmt web install-browser [--with-deps] [--dry-run]
2537
+ lmt web start --env local|dev [--device mobile|desktop] [--headed] [--fresh] [--timeout 30s]
2538
+ lmt web inspect --session ID [--full-page] [--timeout 30s]
2539
+ lmt web click --session ID --ref REF
2540
+ lmt web fill --session ID --ref REF --value TEXT [--secret]
2541
+ lmt web read --session ID --ref REF
2542
+ lmt web clipboard --session ID
2543
+ lmt web paste --session ID --ref REF
2544
+ lmt web check --session ID --ref REF --stdin
2545
+ lmt web clipboard-check --session ID --ref REF
2546
+ lmt web select --session ID --ref REF --value OPTION
2547
+ lmt web scroll --session ID [--ref REF] [--x PIXELS] [--y PIXELS]
2548
+ lmt web navigate --session ID --url PATH_OR_URL
2549
+ lmt web zerodha-login --session ID [--timeout 30s]
2550
+ lmt web zerodha-otp --session ID --attempt ID --stdin [--timeout 30s]
2551
+ lmt web zerodha-cancel --session ID [--timeout 30s]
2552
+ lmt web zerodha-logout --session ID [--timeout 30s]
2553
+ lmt web operator-login --session ID [--timeout 30s]
2554
+ lmt web operator-logout --session ID [--timeout 30s]
2555
+ lmt web close --session ID [--full-page] [--timeout 30s]
2556
+ lmt web stop --session ID [--timeout 30s]
2557
+
2558
+ Start opens the browser already authenticated by the environment's Controller Session, or reuses
2559
+ its live browser; desktop and mobile share the one Controller Session. Log it in with \`lmt\` (or
2560
+ \`lmt login\` and \`lmt zerodha login\`/\`otp\`); when it is not authenticated start is blocked
2561
+ (exit 4) and opens no browser. --fresh starts a temporary, isolated browser that never reads or
2562
+ writes the Controller Session; the operator-* and zerodha-* login commands run only in --fresh sessions.
2563
+ Mobile emulation and headless mode are the defaults. Inspect and close require the returned session ID.
2564
+ Stop terminates the session's browser and owner without capturing a final screenshot.
2565
+ Each command writes one versioned JSON result to stdout; diagnostics go to stderr.
2566
+ Secret reads and clipboard observations return only present, length and a 12-hex SHA-256 fingerprint.
2567
+ Paste transfers clipboard content into a protected browser field without printing it or submitting the form.
2568
+ Fresh Operator login reads the SMS read credential from ~/.lmt; provision it once with \`lmt credential provision\`.
2569
+ `;
2570
+ function parseTimeout(value) {
2571
+ const match = /^(\d+)(ms|s|m)?$/i.exec(value);
2572
+ if (!match) throw new InvocationError("--timeout must be a positive duration such as 30s, 500ms, or 2m.");
2573
+ const number = Number(match[1]);
2574
+ const multiplier = match[2]?.toLowerCase() === "m" ? 6e4 : match[2]?.toLowerCase() === "s" ? 1e3 : 1;
2575
+ const timeoutMs = number * multiplier;
2576
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_TIMEOUT_MS) {
2577
+ throw new InvocationError("--timeout must be between 1ms and 10m.");
2578
+ }
2579
+ return timeoutMs;
2580
+ }
2581
+ async function parseCommand(argv) {
2582
+ const valueOptions = /* @__PURE__ */ new Set(["--timeout", "--session", ...Object.values(ACTIONS).flatMap((definition2) => definition2.values)]);
2583
+ const flagOptions = /* @__PURE__ */ new Set(["--full-page", ...Object.values(ACTIONS).flatMap((definition2) => definition2.flags)]);
2584
+ let actionIndex = 0;
2585
+ while (argv[actionIndex]?.startsWith("--")) {
2586
+ const option = argv[actionIndex];
2587
+ if (flagOptions.has(option)) {
2588
+ actionIndex += 1;
2589
+ } else if (valueOptions.has(option)) {
2590
+ const value = argv[actionIndex + 1];
2591
+ if (value === void 0 || value.startsWith("--")) throw new InvocationError(`${option} requires a value.`);
2592
+ actionIndex += 2;
2593
+ } else {
2594
+ throw new InvocationError(`Unknown option: ${option}`);
2595
+ }
2596
+ }
2597
+ const action = argv[actionIndex];
2598
+ if (!isAction(action)) throw new InvocationError("Choose a supported browser command. Run `lmt web --help` for usage.");
2599
+ const rest = [...argv.slice(0, actionIndex), ...argv.slice(actionIndex + 1)];
2600
+ const definition = ACTIONS[action];
2601
+ const values = /* @__PURE__ */ new Map();
2602
+ const flags = /* @__PURE__ */ new Set();
2603
+ const allowedFlags = /* @__PURE__ */ new Set([...action === "start" || action === "stop" ? [] : ["--full-page"], ...definition.flags]);
2604
+ const allowedValues = /* @__PURE__ */ new Set(["--timeout", ...action === "start" ? [] : ["--session"], ...definition.values]);
2605
+ for (let index = 0; index < rest.length; index += 1) {
2606
+ const option = rest[index];
2607
+ if (!option.startsWith("--")) throw new InvocationError("Unexpected positional argument.");
2608
+ if (allowedFlags.has(option)) {
2609
+ if (flags.has(option)) throw new InvocationError(`${option} may only be supplied once.`);
2610
+ flags.add(option);
2611
+ continue;
2612
+ }
2613
+ if (!allowedValues.has(option)) throw new InvocationError(`Unknown option: ${option}`);
2614
+ if (values.has(option)) throw new InvocationError(`${option} may only be supplied once.`);
2615
+ const value = rest[index + 1];
2616
+ if (value === void 0 || value.startsWith("--")) throw new InvocationError(`${option} requires a value.`);
2617
+ values.set(option, value);
2618
+ index += 1;
2619
+ }
2620
+ const timeoutMs = values.has("--timeout") ? parseTimeout(values.get("--timeout")) : DEFAULT_TIMEOUT_MS2;
2621
+ if (action === "start") {
2622
+ const mode = values.get("--device") ?? "mobile";
2623
+ if (mode !== "mobile" && mode !== "desktop") {
2624
+ throw new InvocationError("--device must be mobile or desktop.");
2625
+ }
2626
+ return {
2627
+ action,
2628
+ environment: values.get("--env") ?? "",
2629
+ mode,
2630
+ headless: !flags.has("--headed"),
2631
+ fresh: flags.has("--fresh"),
2632
+ timeoutMs
2633
+ };
2634
+ }
2635
+ const sessionId = values.get("--session");
2636
+ if (!isSessionId(sessionId)) {
2637
+ throw new InvocationError("--session requires the 32-character session ID returned by start.", "invalid_session_id");
2638
+ }
2639
+ let payload;
2640
+ try {
2641
+ payload = await definition.build({ values, flags, readOtp: () => readOtp(timeoutMs), readInput: () => readInput(timeoutMs) });
2642
+ } catch (error) {
2643
+ throw new InvocationError(error instanceof Error ? error.message : "The action input could not be read.", error instanceof InvocationError ? error.code : "action_failed", action);
2644
+ }
2645
+ if (payload.action === "start") throw new InvocationError("Start requires an environment.");
2646
+ return { ...payload, sessionId, fullPage: flags.has("--full-page"), timeoutMs };
2647
+ }
2648
+ function webCommand(name) {
2649
+ return `web ${name}`;
2650
+ }
2651
+ function writeResult(result, command) {
2652
+ const { version, action, ...fields } = result;
2653
+ process.stdout.write(`${JSON.stringify({ version, command: command ?? (action ? webCommand(action) : "web"), ...fields })}
2654
+ `);
2655
+ }
2656
+ function emit(result, diagnostic, command) {
2657
+ writeResult(result, command);
2658
+ if (diagnostic) process.stderr.write(`${diagnostic}
2659
+ `);
2660
+ }
2661
+ async function readInput(timeoutMs) {
2662
+ if (process.stdin.isTTY) throw new InvocationError("Pipe the expected value on stdin.");
2663
+ let input = "";
2664
+ const timer = setTimeout(() => process.stdin.destroy(new Error("Input deadline exceeded.")), timeoutMs);
2665
+ try {
2666
+ for await (const chunk of process.stdin) {
2667
+ input += chunk.toString();
2668
+ if (Buffer.byteLength(input) > 16384) throw new InvocationError("Expected value is too large.");
2669
+ }
2670
+ return input.replace(/\r?\n$/, "");
2671
+ } catch {
2672
+ throw new InvocationError("Expected value could not be read from stdin.");
2673
+ } finally {
2674
+ input = "";
2675
+ clearTimeout(timer);
2676
+ }
2677
+ }
2678
+ async function readOtp(timeoutMs) {
2679
+ let input = "";
2680
+ const timer = setTimeout(() => process.stdin.destroy(new Error("OTP input exceeded its deadline.")), timeoutMs);
2681
+ try {
2682
+ for await (const chunk of process.stdin) {
2683
+ input += chunk.toString();
2684
+ if (input.length > 64) throw new InvocationError("OTP must contain six digits.");
2685
+ }
2686
+ const otp = input.trim();
2687
+ if (!/^\d{6}$/.test(otp)) throw new InvocationError("OTP must contain six digits.");
2688
+ return otp;
2689
+ } finally {
2690
+ input = "";
2691
+ clearTimeout(timer);
2692
+ }
2693
+ }
2694
+ async function runOwner(argv) {
2695
+ const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment, authentication] = argv;
2696
+ if (!isSessionId(sessionId) || !origin || modeValue !== "mobile" && modeValue !== "desktop" || headlessValue !== "headless" && headlessValue !== "headed" || !timeoutValue || !/^\d+$/.test(timeoutValue)) {
2697
+ process.exitCode = 2;
2698
+ return;
2699
+ }
2700
+ await runSessionOwner({
2701
+ sessionId,
2702
+ origin,
2703
+ environment: environment === "dev" ? "dev" : "local",
2704
+ mode: modeValue,
2705
+ headless: headlessValue === "headless",
2706
+ timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS),
2707
+ controller: authentication === "controller"
2708
+ });
2709
+ }
2710
+ async function installChromium(options, progress) {
2711
+ const require2 = createRequire(import.meta.url);
2712
+ const browserCli = path4.join(path4.dirname(require2.resolve("playwright/package.json")), "cli.js");
2713
+ return new Promise((resolve, reject) => {
2714
+ const child = spawn2(process.execPath, [browserCli, "install", ...options, "chromium"], {
2715
+ stdio: ["ignore", "pipe", "pipe"]
2716
+ });
2717
+ child.stdout.pipe(progress, { end: false });
2718
+ child.stderr.pipe(progress, { end: false });
2719
+ child.once("error", reject);
2720
+ child.once("close", (status) => resolve(status ?? 1));
2721
+ });
2722
+ }
2723
+ async function installBrowser(options) {
2724
+ const allowed = /* @__PURE__ */ new Set(["--with-deps", "--dry-run"]);
2725
+ if (options.some((option) => !allowed.has(option)) || new Set(options).size !== options.length) {
2726
+ writeResult({ version: 1, action: "install-browser", outcome: "failure", error: { code: "invalid_invocation", message: "Use install-browser [--with-deps] [--dry-run]." } });
2727
+ process.exitCode = 2;
2728
+ return;
2729
+ }
2730
+ try {
2731
+ const code = await installChromium(options, process.stderr);
2732
+ writeResult({ version: 1, action: "install-browser", outcome: code === 0 ? "success" : "failure", ...code === 0 ? {} : { error: { code: "browser_install_failed", message: "Chromium setup failed. Review stderr and retry install-browser." } } });
2733
+ process.exitCode = code;
2734
+ } catch {
2735
+ writeResult({ version: 1, action: "install-browser", outcome: "failure", error: { code: "browser_install_failed", message: "Could not run the installed Playwright runtime. Reinstall lmt and retry." } });
2736
+ process.exitCode = 1;
2737
+ }
2738
+ }
2739
+ async function runWebCli(argv) {
2740
+ if (argv.length === 0 || argv.includes("--help") || argv.includes("-h")) {
2741
+ process.stdout.write(HELP);
2742
+ return;
2743
+ }
2744
+ if (argv[0] === "install-browser" && !argv.includes("--help") && !argv.includes("-h")) {
2745
+ await installBrowser(argv.slice(1));
2746
+ return;
2747
+ }
2748
+ if (argv[0] === "credential") {
2749
+ const message = "lmt web no longer stores credentials. Provision the SMS read credential with `lmt credential provision`.";
2750
+ writeResult({ version: 1, outcome: "failure", error: { code: "invalid_invocation", message } }, "web credential");
2751
+ process.stderr.write(`invalid_invocation: ${message}
2752
+ `);
2753
+ process.exitCode = 2;
2754
+ return;
2755
+ }
2756
+ if (argv[0] === "__owner") {
2757
+ await runOwner(argv.slice(1));
2758
+ return;
2759
+ }
2760
+ if (argv[0] === "interactive") {
2761
+ const message = `lmt web interactive is no longer available. ${CONTROLLER_LOGIN_STEP}; \`lmt web start\` then opens an authenticated browser.`;
2762
+ writeResult({ version: 1, outcome: "failure", error: { code: "invalid_invocation", message } }, "web interactive");
2763
+ process.stderr.write(`invalid_invocation: ${message}
2764
+ `);
2765
+ process.exitCode = 2;
2766
+ return;
2767
+ }
2768
+ let command;
2769
+ try {
2770
+ command = await parseCommand(argv);
2771
+ let result;
2772
+ if (command.action === "start") {
2773
+ ({ result } = await startSession(command));
2774
+ } else {
2775
+ result = await requestSession(command);
2776
+ }
2777
+ emit(result, result.error ? `${result.error.code}: ${result.error.message}` : void 0);
2778
+ if (result.outcome !== "success") {
2779
+ process.exitCode = result.outcome === "blocked" ? 4 : ["invalid_target", "invalid_invocation", "fresh_session_required"].includes(result.error?.code ?? "") ? 2 : 1;
2780
+ }
2781
+ } catch (error) {
2782
+ const action = command?.action ?? (error instanceof InvocationError ? error.action : void 0) ?? (isAction(argv[0]) ? argv[0] : void 0);
2783
+ const code = error instanceof OwnerRegistryError ? "owner_registry_unavailable" : error instanceof InvocationError ? error.code : "action_failed";
2784
+ const message = error instanceof Error ? error.message : "The lmt web command failed.";
2785
+ const result = failureResult(action ?? "start", code, message);
2786
+ emit(result, `${code}: ${message}`, action ? void 0 : "web");
2787
+ process.exitCode = error instanceof InvocationError && error.code !== "action_failed" ? 2 : 1;
2788
+ return;
2789
+ }
2790
+ }
2791
+
2792
+ export {
2793
+ socketPathFor,
2794
+ requestSession,
2795
+ listLiveOwners,
2796
+ startSession,
2797
+ installChromium,
2798
+ runWebCli
2799
+ };