@ai-digital-factory/lmt-web-cli 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +112 -17
- package/dist/index.js +506 -92
- package/package.json +2 -2
package/README.md
CHANGED
|
@@ -25,14 +25,15 @@ separately from `lmt-cli` and does not use profiles:
|
|
|
25
25
|
npx --yes @ai-digital-factory/lmt-web-cli@latest credential provision --env dev --credential-stdin
|
|
26
26
|
```
|
|
27
27
|
|
|
28
|
-
Start a
|
|
28
|
+
Start or restore a desktop session against Dev and keep the returned `sessionId` for every
|
|
29
29
|
later command:
|
|
30
30
|
|
|
31
31
|
```sh
|
|
32
32
|
npx --yes @ai-digital-factory/lmt-web-cli@latest start --env dev --device desktop
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
A new session starts on the Home Page; a restored session follows Web’s existing
|
|
36
|
+
authentication state. Use the same session to log into the Operator when required;
|
|
36
37
|
if a Zerodha login is required, start that flow and submit the current OTP through
|
|
37
38
|
standard input only, using the `attemptId` returned by `zerodha-login`:
|
|
38
39
|
|
|
@@ -68,7 +69,7 @@ complete command list.
|
|
|
68
69
|
|
|
69
70
|
## Install
|
|
70
71
|
|
|
71
|
-
Requires Node.js 22 or later, Chromium, and a macOS or Linux session owner.
|
|
72
|
+
Requires Node.js 22.13 or later, Chromium, and a macOS or Linux session owner.
|
|
72
73
|
Install the CLI and its browser:
|
|
73
74
|
|
|
74
75
|
```sh
|
|
@@ -106,7 +107,8 @@ Use `--device desktop` for the 1440 × 900 desktop preset or `--headed` to show
|
|
|
106
107
|
the browser window. Desktop uses scale 1, no touch emulation, and the installed
|
|
107
108
|
Chromium runtime's desktop user agent. Results report the actual preset,
|
|
108
109
|
viewport, scale, touch support, and user agent. Changing device or browser
|
|
109
|
-
visibility
|
|
110
|
+
visibility is selected by `start`. Mobile and desktop keep independent saved sessions.
|
|
111
|
+
To change visibility for an already-live device, close its browser first or use `--fresh`.
|
|
110
112
|
A visible browser requires an active desktop display. All automated browser tests
|
|
111
113
|
run headlessly, including mobile and desktop acceptance. `pnpm test` requires no
|
|
112
114
|
desktop display or Xvfb.
|
|
@@ -115,6 +117,11 @@ Quitting the browser from its Dock menu or closing its session window also ends
|
|
|
115
117
|
the browser session and its local owner. This does not log out the Operator or
|
|
116
118
|
stop Broker activity. A later `inspect` reports `session_not_found`.
|
|
117
119
|
|
|
120
|
+
On Dev Deployment, Dev Operator opens the Web UI with `?captureSafe=1` on the first
|
|
121
|
+
navigation (for example `https://dev-live-market-terminal.piyush-arora.in/?captureSafe=1`)
|
|
122
|
+
so Capture-safe Mode stays enabled for that tab through navigation and refresh.
|
|
123
|
+
`lmt-web` screenshots still mask access-token value fields automatically without that flag.
|
|
124
|
+
|
|
118
125
|
```sh
|
|
119
126
|
lmt-web start --env dev
|
|
120
127
|
lmt-web inspect --session SESSION_ID
|
|
@@ -128,27 +135,85 @@ the page or taking a final screenshot. It also works after the browser window ha
|
|
|
128
135
|
been closed manually. Supply the session ID returned by `start`; other sessions
|
|
129
136
|
remain running. Like `close`, it does not log out or stop Broker activity.
|
|
130
137
|
|
|
131
|
-
`start`
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
138
|
+
`start` automatically restores saved authentication for the selected environment,
|
|
139
|
+
Web Origin, and device, or reuses that device's live browser and returns its session
|
|
140
|
+
ID. Every later command needs that ID. Reusing a live browser preserves its current
|
|
141
|
+
page; concurrent commands run in order. Separate mobile and desktop sessions each
|
|
142
|
+
need their own initial login and restore independently thereafter.
|
|
143
|
+
|
|
144
|
+
Closing, stopping, losing the owner process, or restarting the machine preserves
|
|
145
|
+
saved authentication. A subsequent `start` creates a new browser owner and restores
|
|
146
|
+
the same Operator Session cookies when still valid. Gate's expiry, daily-visit
|
|
147
|
+
renewal, revocation, and per-Operator-Session Zerodha authentication remain authoritative.
|
|
148
|
+
Restoration never submits credentials or starts an OTP Challenge. Results include
|
|
149
|
+
`authentication.status` when a recognized Web surface is visible: `operator_login_required`,
|
|
150
|
+
`zerodha_login_required`, or `authenticated`. Follow that state before logging in.
|
|
151
|
+
|
|
152
|
+
Use `start --env dev --device desktop --fresh` for temporary, isolated login-flow
|
|
153
|
+
testing. It neither reads nor overwrites the normal saved session, and its own
|
|
154
|
+
login is not retained after closure. Closing a browser does not log out, revoke an
|
|
155
|
+
Operator Session, or stop Broker activity. Explicit Zerodha logout is retained;
|
|
156
|
+
Operator logout removes that browser's saved Operator Session. Neither logs out
|
|
157
|
+
another device.
|
|
158
|
+
|
|
159
|
+
The CLI saves only cookies applicable to the configured Web Origin, during the
|
|
160
|
+
live session and before successful commands return. It does not persist page
|
|
161
|
+
content, passwords, OTPs, localStorage, or third-party Kite cookies, and never imports
|
|
162
|
+
`lmt` authentication. Saved cookies are replayable authentication stored in private
|
|
163
|
+
SQLite files under `~/.lmt-web/sessions` (directory mode 0700, files 0600). Anyone
|
|
164
|
+
acting as the same OS user can reuse them; keychain encryption is not required.
|
|
165
|
+
`LMT_WEB_STATE_DIRECTORY` overrides the private parent directory for isolated setups.
|
|
166
|
+
Unsafe ownership/permissions, symlinks, or corrupt state fail explicitly with
|
|
167
|
+
`saved_session_unavailable`; there is no silent fallback that overwrites bad state.
|
|
168
|
+
Cookies already committed before a process failure can be restored; a failure between
|
|
169
|
+
a server change and its local checkpoint can still require login. Server-side logout
|
|
170
|
+
or revocation cannot be reversed by an older saved cookie.
|
|
171
|
+
|
|
172
|
+
Results distinguish action completion from the browser's authentication state.
|
|
173
|
+
For example, a successful `operator-login` can return:
|
|
174
|
+
|
|
175
|
+
```json
|
|
176
|
+
{"outcome":"success","authentication":{"status":"zerodha_login_required","nextAction":"zerodha-login"}}
|
|
177
|
+
```
|
|
178
|
+
|
|
179
|
+
`authentication.status` is `operator_login_required`, `zerodha_login_required`,
|
|
180
|
+
`zerodha_session_restoring`, `authenticated`, or `unknown`. It observes the
|
|
181
|
+
rendered session surface, including Settings, rather than inferring admission
|
|
182
|
+
from a URL or saved token presence.
|
|
183
|
+
Unrecognized, conflicting, or unavailable page observations return `unknown`;
|
|
184
|
+
a generic loading screen that exceeds the command deadline also returns
|
|
185
|
+
`unknown` alongside the existing action error. `stop` has no page observation
|
|
186
|
+
and returns `unknown`.
|
|
187
|
+
|
|
188
|
+
When present, `authentication.nextAction` names `operator-login`, `zerodha-login`,
|
|
189
|
+
or `zerodha-otp`. The OTP action includes `authentication.attemptId` for the active
|
|
190
|
+
challenge, also available through `inspect`. It is omitted while an attempt is
|
|
191
|
+
submitting or is no longer accepting OTPs; inspect the page and its error before
|
|
192
|
+
deciding to retry. Supply the OTP through `--stdin` with that attempt ID in the
|
|
193
|
+
same browser session. No login or OTP submission is performed by inspection.
|
|
194
|
+
After successful OTP submission, `authentication.status` is `authenticated`.
|
|
195
|
+
Keep that session alive for Market Watch and pairing QA; `lmt` authentication
|
|
196
|
+
does not transfer to a new `lmt-web` session.
|
|
139
197
|
|
|
140
198
|
Each command writes one versioned JSON result to stdout and diagnostics to
|
|
141
199
|
stderr. Results contain the action outcome, current URL, visible page text and
|
|
142
|
-
controls, emulated device and actual viewport, and screenshot
|
|
200
|
+
controls, `authentication`, emulated device and actual viewport, and screenshot
|
|
201
|
+
outcome. Images
|
|
143
202
|
are written to unique absolute paths in an owner-only temporary directory;
|
|
144
|
-
screenshots
|
|
203
|
+
screenshots solid-mask only Zerodha access-token value fields marked with
|
|
204
|
+
`data-lmt-sensitive="zerodha-access-token"`; other controls and metadata stay visible.
|
|
205
|
+
If protected capture fails, the screenshot outcome is `failed` with no unmasked fallback.
|
|
206
|
+
Field values
|
|
145
207
|
are omitted from JSON observations. Captures wait for document loading, visible
|
|
146
208
|
session-loading or busy indicators, fonts, visible images, and finite animations
|
|
147
209
|
to finish. The page must render text, controls, or visual content; an empty app
|
|
148
210
|
root is not ready. Pages that remain empty reach the command deadline without a
|
|
149
211
|
captured screenshot. Infinite animations do not block capture. `--timeout` sets one deadline for the command's browser action and
|
|
150
|
-
screenshot, from `1ms` through `10m`.
|
|
151
|
-
|
|
212
|
+
screenshot, from `1ms` through `10m`. A recoverable action timeout stops pending loading, waits for the operation to
|
|
213
|
+
settle, and retains a responsive browser. Unsettled work, an unresponsive renderer,
|
|
214
|
+
or an overall inspection/capture deadline can close the owner; use `start` to
|
|
215
|
+
restore saved authentication. Submitted actions remain uncertain and are never
|
|
216
|
+
automatically replayed. Action failures return nonzero; invalid commands and targets return exit code 2.
|
|
152
217
|
The JSON `error.code` is machine-readable:
|
|
153
218
|
|
|
154
219
|
| Code | Exit | Meaning |
|
|
@@ -157,7 +222,8 @@ The JSON `error.code` is machine-readable:
|
|
|
157
222
|
| `browser_unavailable` | 1 | Install Chromium with `lmt-web install-browser`, then retry. |
|
|
158
223
|
| `browser_start_failed` | 1 | The local browser owner exited before its session became ready. |
|
|
159
224
|
| `service_unavailable` | 1 | The selected Web URL could not be reached; start/check that service. |
|
|
160
|
-
| `session_not_found`, `session_unavailable` | 1 | The browser owner ended; start
|
|
225
|
+
| `session_not_found`, `session_unavailable` | 1 | The browser owner ended; use `start` to restore it. |
|
|
226
|
+
| `saved_session_unavailable` | 1 | Check ownership, private permissions, and integrity of saved-session storage. |
|
|
161
227
|
| `deadline_exceeded`, `action_failed` | 1 | The action failed or exceeded its finite deadline. |
|
|
162
228
|
|
|
163
229
|
Screenshots cover the viewport by default. `inspect --full-page` and
|
|
@@ -321,3 +387,32 @@ sets up its own Chromium runtime, verifies packaged runtime modules/configuratio
|
|
|
321
387
|
and reuses all browser acceptance tests. Installed commands and their live owners
|
|
322
388
|
run outside the checkout. Test-only fixture servers provide repeatable responses;
|
|
323
389
|
this check performs no npm publication or live dev login.
|
|
390
|
+
|
|
391
|
+
## Explicit values, equality, scrolling, and full content
|
|
392
|
+
|
|
393
|
+
```sh
|
|
394
|
+
lmt-web read --session ID --ref FIELD
|
|
395
|
+
lmt-web clipboard --session ID
|
|
396
|
+
lmt-web check --session ID --ref FIELD --stdin < /path/to/protected-expectation
|
|
397
|
+
lmt-web clipboard-check --session ID --ref FIELD
|
|
398
|
+
lmt-web scroll --session ID --ref FIELD
|
|
399
|
+
lmt-web scroll --session ID --y 600
|
|
400
|
+
lmt-web inspect --session ID --full-page
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
read returns an ordinary field value. clipboard returns content only when it matches
|
|
404
|
+
a verified ordinary field on the current page; unknown or secret content is refused.
|
|
405
|
+
check compares the field with one stdin-supplied expectation (an optional final newline
|
|
406
|
+
is removed). clipboard-check compares clipboard and field inside the browser session.
|
|
407
|
+
Comparisons return only matches: true or matches: false. An unreadable field/clipboard
|
|
408
|
+
or an expired reference is an error without a matches result. Inspect/action observations
|
|
409
|
+
continue to redact secrets, including revealed fields.
|
|
410
|
+
|
|
411
|
+
Referenced scrolling brings the control into view through its ancestors. Pixel scrolling
|
|
412
|
+
targets the largest scrolling content area. Results include before/after positions and
|
|
413
|
+
moved, already_visible, or at_boundary. Each observation expires older references.
|
|
414
|
+
|
|
415
|
+
Full-page capture temporarily expands inner scrolling content and restores its styles
|
|
416
|
+
and positions afterward. A screenshot with unexpanded clipped content reports failed;
|
|
417
|
+
browser-action and screenshot outcomes remain separate. Screenshots retain the existing
|
|
418
|
+
rendered-page confidentiality behavior: protect their files as you would the visible UI.
|
package/dist/index.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// src/index.ts
|
|
4
4
|
import { spawn as spawn2 } from "node:child_process";
|
|
5
5
|
import { createRequire } from "node:module";
|
|
6
|
-
import
|
|
6
|
+
import path6 from "node:path";
|
|
7
7
|
import { fileURLToPath as fileURLToPath3 } from "node:url";
|
|
8
8
|
|
|
9
9
|
// ../cli/src/profile-store.ts
|
|
@@ -477,6 +477,147 @@ function resolveCliTarget(env = process.env, configPath = DEFAULT_CONFIG_PATH) {
|
|
|
477
477
|
};
|
|
478
478
|
}
|
|
479
479
|
|
|
480
|
+
// src/saved-session.ts
|
|
481
|
+
import { execFileSync } from "node:child_process";
|
|
482
|
+
import { createHash } from "node:crypto";
|
|
483
|
+
import { constants as constants2 } from "node:fs";
|
|
484
|
+
import { lstat as lstat2, mkdir as mkdir2, open as open2 } from "node:fs/promises";
|
|
485
|
+
import os from "node:os";
|
|
486
|
+
import path3 from "node:path";
|
|
487
|
+
import { DatabaseSync } from "node:sqlite";
|
|
488
|
+
var SavedSessionError = class extends Error {
|
|
489
|
+
constructor() {
|
|
490
|
+
super("Could not access protected Web CLI session storage. Check its ownership, permissions, and integrity.");
|
|
491
|
+
}
|
|
492
|
+
};
|
|
493
|
+
async function assertPrivate(filename, directory) {
|
|
494
|
+
const entry = await lstat2(filename);
|
|
495
|
+
if ((directory ? !entry.isDirectory() : !entry.isFile()) || entry.isSymbolicLink() || entry.uid !== process.getuid?.() || (entry.mode & 511) !== (directory ? 448 : 384)) {
|
|
496
|
+
throw new SavedSessionError();
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
var SavedSession = class _SavedSession {
|
|
500
|
+
constructor(database, target) {
|
|
501
|
+
this.database = database;
|
|
502
|
+
this.target = target;
|
|
503
|
+
}
|
|
504
|
+
database;
|
|
505
|
+
target;
|
|
506
|
+
static async open(target) {
|
|
507
|
+
let database;
|
|
508
|
+
try {
|
|
509
|
+
const root = process.env.LMT_WEB_STATE_DIRECTORY || path3.join(os.homedir(), ".lmt-web");
|
|
510
|
+
await mkdir2(root, { recursive: true, mode: 448 });
|
|
511
|
+
await assertPrivate(root, true);
|
|
512
|
+
const directory = path3.join(root, "sessions");
|
|
513
|
+
await mkdir2(directory, { mode: 448 }).catch((error) => {
|
|
514
|
+
if (error.code !== "EEXIST") throw error;
|
|
515
|
+
});
|
|
516
|
+
await assertPrivate(directory, true);
|
|
517
|
+
const key = createHash("sha256").update(JSON.stringify([target.environment, target.origin, target.mode])).digest("hex");
|
|
518
|
+
const filename = path3.join(directory, `${key}.sqlite`);
|
|
519
|
+
const file = await open2(filename, constants2.O_CREAT | constants2.O_EXCL | constants2.O_WRONLY | constants2.O_NOFOLLOW, 384).catch((error) => {
|
|
520
|
+
if (error.code !== "EEXIST") throw error;
|
|
521
|
+
});
|
|
522
|
+
await file?.close();
|
|
523
|
+
await assertPrivate(filename, false);
|
|
524
|
+
database = new DatabaseSync(filename);
|
|
525
|
+
database.exec("PRAGMA busy_timeout = 25; PRAGMA secure_delete = ON;");
|
|
526
|
+
const store = new _SavedSession(database, target);
|
|
527
|
+
await store.reserve(Date.now() + 5e3, () => {
|
|
528
|
+
database.exec("CREATE TABLE IF NOT EXISTS session (id INTEGER PRIMARY KEY CHECK(id=1), state TEXT, owner TEXT)");
|
|
529
|
+
database.exec("INSERT OR IGNORE INTO session VALUES (1, NULL, NULL)");
|
|
530
|
+
});
|
|
531
|
+
return store;
|
|
532
|
+
} catch {
|
|
533
|
+
database?.close();
|
|
534
|
+
throw new SavedSessionError();
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
async reserve(deadlineAt, action) {
|
|
538
|
+
while (true) {
|
|
539
|
+
try {
|
|
540
|
+
this.database.exec("BEGIN IMMEDIATE");
|
|
541
|
+
break;
|
|
542
|
+
} catch (error) {
|
|
543
|
+
if (!/locked|busy/i.test(String(error)) || Date.now() >= deadlineAt) throw new SavedSessionError();
|
|
544
|
+
await new Promise((resolve) => setTimeout(resolve, 20));
|
|
545
|
+
}
|
|
546
|
+
}
|
|
547
|
+
try {
|
|
548
|
+
const result = action();
|
|
549
|
+
this.database.exec("COMMIT");
|
|
550
|
+
return result;
|
|
551
|
+
} catch (error) {
|
|
552
|
+
this.database.exec("ROLLBACK");
|
|
553
|
+
throw error;
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
owner() {
|
|
557
|
+
try {
|
|
558
|
+
const row = this.database.prepare("SELECT owner FROM session WHERE id=1").get();
|
|
559
|
+
if (!row?.owner) return void 0;
|
|
560
|
+
const owner = JSON.parse(String(row.owner));
|
|
561
|
+
if (!/^[a-f0-9]{32}$/.test(owner.sessionId) || !Number.isSafeInteger(owner.pid) || owner.pid < 1 || typeof owner.headless !== "boolean" || typeof owner.processStartedAt !== "string") throw new SavedSessionError();
|
|
562
|
+
try {
|
|
563
|
+
process.kill(owner.pid, 0);
|
|
564
|
+
} catch (error) {
|
|
565
|
+
if (error.code !== "ESRCH") throw error;
|
|
566
|
+
return void 0;
|
|
567
|
+
}
|
|
568
|
+
try {
|
|
569
|
+
return this.processStartedAt(owner.pid) === owner.processStartedAt ? owner : void 0;
|
|
570
|
+
} catch {
|
|
571
|
+
try {
|
|
572
|
+
process.kill(owner.pid, 0);
|
|
573
|
+
} catch (error) {
|
|
574
|
+
if (error.code === "ESRCH") return void 0;
|
|
575
|
+
}
|
|
576
|
+
throw new SavedSessionError();
|
|
577
|
+
}
|
|
578
|
+
} catch {
|
|
579
|
+
throw new SavedSessionError();
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
processStartedAt(pid) {
|
|
583
|
+
const startedAt = execFileSync("ps", ["-p", String(pid), "-o", "lstart="], {
|
|
584
|
+
encoding: "utf8",
|
|
585
|
+
timeout: 1e3,
|
|
586
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
587
|
+
env: { ...process.env, LC_ALL: "C", TZ: "UTC" }
|
|
588
|
+
}).trim();
|
|
589
|
+
if (!startedAt) throw new SavedSessionError();
|
|
590
|
+
return startedAt;
|
|
591
|
+
}
|
|
592
|
+
setOwner(owner) {
|
|
593
|
+
this.database.prepare("UPDATE session SET owner=? WHERE id=1").run(JSON.stringify({ ...owner, processStartedAt: this.processStartedAt(owner.pid) }));
|
|
594
|
+
}
|
|
595
|
+
load() {
|
|
596
|
+
try {
|
|
597
|
+
const row = this.database.prepare("SELECT state FROM session WHERE id=1").get();
|
|
598
|
+
if (!row?.state) return void 0;
|
|
599
|
+
const state = JSON.parse(String(row.state));
|
|
600
|
+
if (!Array.isArray(state.cookies) || !Array.isArray(state.origins)) throw new Error();
|
|
601
|
+
return state;
|
|
602
|
+
} catch {
|
|
603
|
+
throw new SavedSessionError();
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
async save(state) {
|
|
607
|
+
const hostname = new URL(this.target.origin).hostname;
|
|
608
|
+
const scoped = {
|
|
609
|
+
cookies: state.cookies.filter((cookie) => cookie.domain === hostname || cookie.domain === `.${hostname}`),
|
|
610
|
+
origins: state.origins.filter((origin) => origin.origin === this.target.origin)
|
|
611
|
+
};
|
|
612
|
+
await this.reserve(Date.now() + 2e3, () => {
|
|
613
|
+
this.database.prepare("UPDATE session SET state=? WHERE id=1").run(JSON.stringify(scoped));
|
|
614
|
+
});
|
|
615
|
+
}
|
|
616
|
+
close() {
|
|
617
|
+
this.database.close();
|
|
618
|
+
}
|
|
619
|
+
};
|
|
620
|
+
|
|
480
621
|
// src/actions.ts
|
|
481
622
|
var InvocationError = class extends Error {
|
|
482
623
|
constructor(message, code = "invalid_invocation", action) {
|
|
@@ -493,7 +634,7 @@ function required(input, option) {
|
|
|
493
634
|
return value;
|
|
494
635
|
}
|
|
495
636
|
var ACTIONS = {
|
|
496
|
-
start: { control: false, flags: ["--headed"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
|
|
637
|
+
start: { control: false, flags: ["--headed", "--fresh"], values: ["--env", "--device"], build: () => ({ action: "start" }) },
|
|
497
638
|
inspect: { control: false, flags: [], values: [], build: () => ({ action: "inspect" }) },
|
|
498
639
|
close: { control: false, flags: [], values: [], build: () => ({ action: "close" }) },
|
|
499
640
|
stop: { control: false, flags: [], values: [], build: () => ({ action: "stop" }) },
|
|
@@ -511,6 +652,13 @@ var ACTIONS = {
|
|
|
511
652
|
return { action: "zerodha-otp", attemptId, otp: await input.readOtp() };
|
|
512
653
|
}
|
|
513
654
|
},
|
|
655
|
+
read: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "read", reference: required(input, "--ref") }) },
|
|
656
|
+
clipboard: { control: true, flags: [], values: [], build: () => ({ action: "clipboard" }) },
|
|
657
|
+
check: { control: true, flags: ["--stdin"], values: ["--ref"], build: async (input) => {
|
|
658
|
+
if (!input.flags.has("--stdin")) throw new InvocationError("check requires --stdin.");
|
|
659
|
+
return { action: "check", reference: required(input, "--ref"), expected: await input.readInput() };
|
|
660
|
+
} },
|
|
661
|
+
"clipboard-check": { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "clipboard-check", reference: required(input, "--ref") }) },
|
|
514
662
|
click: { control: true, flags: [], values: ["--ref"], build: (input) => ({ action: "click", reference: required(input, "--ref") }) },
|
|
515
663
|
fill: {
|
|
516
664
|
control: true,
|
|
@@ -541,14 +689,14 @@ function isControlAction(action) {
|
|
|
541
689
|
|
|
542
690
|
// src/config.ts
|
|
543
691
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
544
|
-
import
|
|
692
|
+
import path4 from "node:path";
|
|
545
693
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
546
|
-
var repositoryRoot =
|
|
547
|
-
|
|
694
|
+
var repositoryRoot = path4.resolve(
|
|
695
|
+
path4.dirname(fileURLToPath2(import.meta.url)),
|
|
548
696
|
"../../../../"
|
|
549
697
|
);
|
|
550
698
|
var packagedConfig = fileURLToPath2(new URL("../lmt-web.config.json", import.meta.url));
|
|
551
|
-
var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig :
|
|
699
|
+
var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig : path4.join(repositoryRoot, "lmt.config.json");
|
|
552
700
|
function canonicalOrigin(value, environment) {
|
|
553
701
|
if (typeof value !== "string" || !value.trim()) {
|
|
554
702
|
throw new Error(`lmt-web.config.json does not contain the ${environment} Web URL.`);
|
|
@@ -598,6 +746,7 @@ function failureResult(action, code, message, sessionId = null) {
|
|
|
598
746
|
sessionId,
|
|
599
747
|
action,
|
|
600
748
|
outcome: "failure",
|
|
749
|
+
authentication: { status: "unknown" },
|
|
601
750
|
url: null,
|
|
602
751
|
visibleState: null,
|
|
603
752
|
device: null,
|
|
@@ -608,12 +757,58 @@ function failureResult(action, code, message, sessionId = null) {
|
|
|
608
757
|
|
|
609
758
|
// src/session.ts
|
|
610
759
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
611
|
-
import { chmod as chmod2, lstat as
|
|
760
|
+
import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, unlink as unlink2 } from "node:fs/promises";
|
|
612
761
|
import net from "node:net";
|
|
613
|
-
import
|
|
614
|
-
import
|
|
762
|
+
import os2 from "node:os";
|
|
763
|
+
import path5 from "node:path";
|
|
615
764
|
import { chromium as chromium2 } from "playwright";
|
|
616
765
|
|
|
766
|
+
// ../../packages/crawler/src/full-content.ts
|
|
767
|
+
async function captureFullContent(browserPage, options) {
|
|
768
|
+
const page = browserPage;
|
|
769
|
+
if (!page.evaluate) throw new Error("Full-content capture is unavailable.");
|
|
770
|
+
try {
|
|
771
|
+
await page.evaluate(() => {
|
|
772
|
+
const state = [];
|
|
773
|
+
window.__lmtCapture = state;
|
|
774
|
+
const scrolling = Array.from(document.querySelectorAll("body *")).filter((element) => {
|
|
775
|
+
const style = getComputedStyle(element);
|
|
776
|
+
return /(auto|scroll)/.test(style.overflowY) && element.scrollHeight > element.clientHeight;
|
|
777
|
+
});
|
|
778
|
+
const expand = /* @__PURE__ */ new Set();
|
|
779
|
+
for (const element of scrolling) {
|
|
780
|
+
for (let parent = element; parent; parent = parent.parentElement) expand.add(parent);
|
|
781
|
+
}
|
|
782
|
+
for (const element of expand) {
|
|
783
|
+
state.push({ element, style: element.getAttribute("style"), x: element.scrollLeft, y: element.scrollTop });
|
|
784
|
+
element.style.setProperty("height", "auto", "important");
|
|
785
|
+
element.style.setProperty("max-height", "none", "important");
|
|
786
|
+
element.style.setProperty("overflow", "visible", "important");
|
|
787
|
+
element.style.setProperty("flex-shrink", "0", "important");
|
|
788
|
+
element.scrollTop = 0;
|
|
789
|
+
}
|
|
790
|
+
});
|
|
791
|
+
await page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
792
|
+
const complete = await page.evaluate(() => Array.from(document.querySelectorAll("body *")).every((element) => {
|
|
793
|
+
const style = getComputedStyle(element);
|
|
794
|
+
return style.display === "none" || element.clientHeight === 0 || !/(auto|scroll|hidden|clip)/.test(style.overflowY) || element.scrollHeight <= element.clientHeight + 1;
|
|
795
|
+
}));
|
|
796
|
+
if (!complete) throw new Error("Full-content capture could not expand all clipped content.");
|
|
797
|
+
return await page.screenshot({ ...options, fullPage: true });
|
|
798
|
+
} finally {
|
|
799
|
+
await page.evaluate(() => {
|
|
800
|
+
const owner = window;
|
|
801
|
+
for (const { element, style, x, y } of (owner.__lmtCapture ?? []).reverse()) {
|
|
802
|
+
if (style === null) element.removeAttribute("style");
|
|
803
|
+
else element.setAttribute("style", style);
|
|
804
|
+
element.scrollLeft = x;
|
|
805
|
+
element.scrollTop = y;
|
|
806
|
+
}
|
|
807
|
+
delete owner.__lmtCapture;
|
|
808
|
+
}).catch(() => void 0);
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
|
|
617
812
|
// ../../packages/crawler/src/controls.ts
|
|
618
813
|
import { randomBytes } from "node:crypto";
|
|
619
814
|
var ControlError = class extends Error {
|
|
@@ -623,6 +818,7 @@ var ControlError = class extends Error {
|
|
|
623
818
|
}
|
|
624
819
|
code;
|
|
625
820
|
};
|
|
821
|
+
var SENSITIVE_FIELD_PATTERN = "password|credential|secret|token|otp|one.time|username|user.?id|api.?key|pin";
|
|
626
822
|
function controlSignature(node) {
|
|
627
823
|
if (!node.isConnected) return null;
|
|
628
824
|
return JSON.stringify([
|
|
@@ -657,8 +853,8 @@ var PageControls = class {
|
|
|
657
853
|
return result;
|
|
658
854
|
}
|
|
659
855
|
async learnSecrets() {
|
|
660
|
-
const values = await this.page.evaluate(() => {
|
|
661
|
-
const sensitive =
|
|
856
|
+
const values = await this.page.evaluate((pattern) => {
|
|
857
|
+
const sensitive = new RegExp(pattern, "i");
|
|
662
858
|
const found = Array.from(document.querySelectorAll("input, textarea")).filter((element) => sensitive.test([
|
|
663
859
|
element.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, ""),
|
|
664
860
|
...Array.from(element.labels ?? []).map((label) => label.innerText)
|
|
@@ -667,15 +863,16 @@ var PageControls = class {
|
|
|
667
863
|
if (match[1]) found.push(match[1]);
|
|
668
864
|
}
|
|
669
865
|
return found;
|
|
670
|
-
});
|
|
866
|
+
}, SENSITIVE_FIELD_PATTERN);
|
|
671
867
|
this.registerSecrets(...values);
|
|
672
868
|
}
|
|
673
869
|
async screenshotMasks() {
|
|
870
|
+
const stable = this.page.locator('[data-lmt-sensitive="zerodha-access-token"]');
|
|
674
871
|
try {
|
|
675
872
|
await this.learnSecrets();
|
|
676
873
|
} catch (error) {
|
|
677
874
|
if (this.secrets.size) throw error;
|
|
678
|
-
else return [];
|
|
875
|
+
else return [stable];
|
|
679
876
|
}
|
|
680
877
|
await this.page.evaluate((secrets) => {
|
|
681
878
|
document.querySelectorAll("[data-lmt-secret-mask]").forEach((element) => element.removeAttribute("data-lmt-secret-mask"));
|
|
@@ -685,7 +882,7 @@ var PageControls = class {
|
|
|
685
882
|
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", "");
|
|
686
883
|
}
|
|
687
884
|
}, [...this.secrets]);
|
|
688
|
-
return [this.page.locator("[data-lmt-secret-mask]")];
|
|
885
|
+
return [stable, this.page.locator("[data-lmt-secret-mask]")];
|
|
689
886
|
}
|
|
690
887
|
async inspect() {
|
|
691
888
|
await this.learnSecrets();
|
|
@@ -735,9 +932,52 @@ var PageControls = class {
|
|
|
735
932
|
async act(command, timeoutMs, onSubmit = () => void 0) {
|
|
736
933
|
this.page.setDefaultTimeout(timeoutMs);
|
|
737
934
|
this.page.setDefaultNavigationTimeout(timeoutMs);
|
|
935
|
+
if (command.action === "read" || command.action === "check" || command.action === "clipboard-check" || command.action === "clipboard") {
|
|
936
|
+
await this.learnSecrets();
|
|
937
|
+
if (command.action === "check") this.registerSecrets(command.expected);
|
|
938
|
+
try {
|
|
939
|
+
const field = command.action === "clipboard" ? void 0 : await this.resolve(command.reference);
|
|
940
|
+
if (command.action === "clipboard-check" || command.action === "clipboard") await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
|
|
941
|
+
if (command.action === "clipboard-check") {
|
|
942
|
+
const matches = await field.evaluate(async (node) => {
|
|
943
|
+
if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
|
|
944
|
+
return await navigator.clipboard.readText() === node.value;
|
|
945
|
+
});
|
|
946
|
+
return { matches };
|
|
947
|
+
}
|
|
948
|
+
const value = command.action === "clipboard" ? await this.page.evaluate(() => navigator.clipboard.readText()) : await field.evaluate((node) => {
|
|
949
|
+
if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) throw new Error("Unreadable field");
|
|
950
|
+
return node.value;
|
|
951
|
+
});
|
|
952
|
+
if (command.action === "check") {
|
|
953
|
+
this.registerSecrets(value);
|
|
954
|
+
return { matches: value === command.expected };
|
|
955
|
+
}
|
|
956
|
+
const sensitive = field && await field.evaluate((node, pattern) => new RegExp(pattern, "i").test(node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ")), SENSITIVE_FIELD_PATTERN);
|
|
957
|
+
if (command.action === "clipboard") {
|
|
958
|
+
const ordinary = await this.page.evaluate(({ value: value2, pattern }) => {
|
|
959
|
+
const sensitive2 = new RegExp(pattern, "i");
|
|
960
|
+
return Array.from(document.querySelectorAll("input,textarea,select")).some((node) => {
|
|
961
|
+
if (!(node instanceof HTMLInputElement || node instanceof HTMLTextAreaElement || node instanceof HTMLSelectElement)) return false;
|
|
962
|
+
const description = node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ");
|
|
963
|
+
return !sensitive2.test(description) && node.value === value2;
|
|
964
|
+
});
|
|
965
|
+
}, { value, pattern: SENSITIVE_FIELD_PATTERN });
|
|
966
|
+
if (!ordinary) {
|
|
967
|
+
this.registerSecrets(value);
|
|
968
|
+
throw new ControlError("secret_read_refused", "Clipboard content has no verified ordinary field source. Use clipboard-check for secret Copy.");
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
if (sensitive || [...this.secrets].some((secret) => value.includes(secret))) throw new ControlError("secret_read_refused", "Use an equality check for secret values.");
|
|
972
|
+
return { value };
|
|
973
|
+
} catch (error) {
|
|
974
|
+
if (error instanceof ControlError) throw error;
|
|
975
|
+
throw new ControlError("value_unreadable", "The field or clipboard could not be read. No comparison result is available.");
|
|
976
|
+
}
|
|
977
|
+
}
|
|
738
978
|
if (command.action === "fill") {
|
|
739
979
|
const element = await this.resolve(command.reference);
|
|
740
|
-
const sensitive = await element.evaluate((node) =>
|
|
980
|
+
const sensitive = await element.evaluate((node, pattern) => new RegExp(pattern, "i").test(node.outerHTML.replace(/value=("[^"]*"|'[^']*')/g, "") + Array.from(node.labels ?? []).map((label) => label.innerText).join(" ")), SENSITIVE_FIELD_PATTERN);
|
|
741
981
|
if (command.secret || sensitive) this.registerSecrets(command.value);
|
|
742
982
|
await element.fill(command.value, { timeout: timeoutMs });
|
|
743
983
|
return;
|
|
@@ -747,6 +987,7 @@ var PageControls = class {
|
|
|
747
987
|
return;
|
|
748
988
|
}
|
|
749
989
|
if (command.action === "click") {
|
|
990
|
+
await this.page.context().grantPermissions(["clipboard-read", "clipboard-write"], { origin: this.origin });
|
|
750
991
|
const element = await this.resolve(command.reference);
|
|
751
992
|
const deadlineAt = Date.now() + timeoutMs;
|
|
752
993
|
await element.click({ trial: true, timeout: timeoutMs });
|
|
@@ -768,12 +1009,33 @@ var PageControls = class {
|
|
|
768
1009
|
}
|
|
769
1010
|
if (command.action === "scroll") {
|
|
770
1011
|
if (!Number.isFinite(command.x) || !Number.isFinite(command.y)) throw new ControlError("invalid_invocation", "Scroll offsets must be finite pixel values.");
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
1012
|
+
const element = command.reference ? await this.resolve(command.reference) : void 0;
|
|
1013
|
+
const scroll = await this.page.evaluate(({ element: node, offsets }) => {
|
|
1014
|
+
const scrollable = (item) => {
|
|
1015
|
+
const style = getComputedStyle(item);
|
|
1016
|
+
return /(auto|scroll)/.test(style.overflow + style.overflowY + style.overflowX) && (item.scrollHeight > item.clientHeight || item.scrollWidth > item.clientWidth);
|
|
1017
|
+
};
|
|
1018
|
+
const containers = [];
|
|
1019
|
+
if (node) {
|
|
1020
|
+
for (let parent = node.parentElement; parent; parent = parent.parentElement) if (scrollable(parent)) containers.push(parent);
|
|
1021
|
+
if (document.scrollingElement && !containers.includes(document.scrollingElement)) containers.push(document.scrollingElement);
|
|
1022
|
+
} else {
|
|
1023
|
+
const candidates = Array.from(document.querySelectorAll("body *")).filter(scrollable);
|
|
1024
|
+
const root = document.scrollingElement;
|
|
1025
|
+
if (root && !/(hidden|clip)/.test(getComputedStyle(root).overflowY) && (root.scrollHeight > root.clientHeight || root.scrollWidth > root.clientWidth)) candidates.push(root);
|
|
1026
|
+
candidates.sort((a, b) => b.clientWidth * b.clientHeight - a.clientWidth * a.clientHeight);
|
|
1027
|
+
containers.push(candidates[0] ?? document.scrollingElement);
|
|
1028
|
+
}
|
|
1029
|
+
const positions = () => containers.map((item) => ({ x: item.scrollLeft, y: item.scrollTop }));
|
|
1030
|
+
const before = positions();
|
|
1031
|
+
if (node) node.scrollIntoView({ block: "nearest", inline: "nearest", behavior: "instant" });
|
|
1032
|
+
else containers[0].scrollBy({ left: offsets.x, top: offsets.y, behavior: "instant" });
|
|
1033
|
+
const after = positions();
|
|
1034
|
+
const moved = before.some((position, i) => position.x !== after[i].x || position.y !== after[i].y);
|
|
1035
|
+
return { outcome: moved ? "moved" : node ? "already_visible" : "at_boundary", before, after };
|
|
1036
|
+
}, { element: element ?? null, offsets: command });
|
|
776
1037
|
await this.page.evaluate(() => new Promise((resolve) => requestAnimationFrame(() => requestAnimationFrame(() => resolve()))));
|
|
1038
|
+
return { scroll };
|
|
777
1039
|
}
|
|
778
1040
|
}
|
|
779
1041
|
};
|
|
@@ -1709,7 +1971,7 @@ async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
|
|
|
1709
1971
|
}
|
|
1710
1972
|
|
|
1711
1973
|
// src/session.ts
|
|
1712
|
-
var SOCKET_DIRECTORY =
|
|
1974
|
+
var SOCKET_DIRECTORY = path5.join(os2.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
|
|
1713
1975
|
var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
1714
1976
|
var OWNER_RESPONSE_GRACE_MS = 1e3;
|
|
1715
1977
|
var OWNER_CLEANUP_GRACE_MS = 2e3;
|
|
@@ -1724,19 +1986,19 @@ async function ensureSessionDirectory() {
|
|
|
1724
1986
|
if (process.platform === "win32") {
|
|
1725
1987
|
throw new Error("lmt-web session sockets are supported on macOS and Linux.");
|
|
1726
1988
|
}
|
|
1727
|
-
await
|
|
1989
|
+
await mkdir3(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
|
|
1728
1990
|
await chmod2(SOCKET_DIRECTORY, 448);
|
|
1729
|
-
const directory = await
|
|
1991
|
+
const directory = await lstat3(SOCKET_DIRECTORY);
|
|
1730
1992
|
const uid = process.getuid?.();
|
|
1731
1993
|
if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
|
|
1732
1994
|
throw new Error("The lmt-web session directory is not owned by the invoking user.");
|
|
1733
1995
|
}
|
|
1734
1996
|
}
|
|
1735
1997
|
function socketPathFor(sessionId) {
|
|
1736
|
-
return
|
|
1998
|
+
return path5.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
|
|
1737
1999
|
}
|
|
1738
2000
|
function artifactsDirectory(sessionId) {
|
|
1739
|
-
return
|
|
2001
|
+
return path5.join(SOCKET_DIRECTORY, "artifacts", sessionId);
|
|
1740
2002
|
}
|
|
1741
2003
|
function redactSecrets(value) {
|
|
1742
2004
|
return value.replace(
|
|
@@ -1754,6 +2016,7 @@ function safeUrl(value) {
|
|
|
1754
2016
|
}
|
|
1755
2017
|
}
|
|
1756
2018
|
function failureCode(error) {
|
|
2019
|
+
if (error instanceof SavedSessionError) return "saved_session_unavailable";
|
|
1757
2020
|
if (error instanceof ControlError) return error.code;
|
|
1758
2021
|
const message = error instanceof Error ? error.message : "";
|
|
1759
2022
|
if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
|
|
@@ -1873,6 +2136,26 @@ async function inspectPage(page, mode, crawler) {
|
|
|
1873
2136
|
const style = getComputedStyle(element);
|
|
1874
2137
|
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
1875
2138
|
};
|
|
2139
|
+
const hasVisible = (selector) => Array.from(document.querySelectorAll(selector)).some(visible);
|
|
2140
|
+
const operatorLogin2 = hasVisible('[data-operator-login-gate="true"]');
|
|
2141
|
+
const zerodhaLogin = hasVisible('[data-zerodha-login-page="true"]');
|
|
2142
|
+
const operatorPage = hasVisible('[data-operator-page="true"]');
|
|
2143
|
+
const restoring = hasVisible('[data-zerodha-restoration="pending"]');
|
|
2144
|
+
let authentication = { status: "unknown" };
|
|
2145
|
+
if (Number(operatorLogin2) + Number(zerodhaLogin) + Number(operatorPage) + Number(restoring) === 1 && !hasVisible('[data-session-loading], [aria-busy="true"]')) {
|
|
2146
|
+
if (operatorLogin2) authentication = { status: "operator_login_required", nextAction: "operator-login" };
|
|
2147
|
+
if (operatorPage) authentication = { status: "authenticated" };
|
|
2148
|
+
if (restoring) authentication = { status: "zerodha_session_restoring" };
|
|
2149
|
+
if (zerodhaLogin) {
|
|
2150
|
+
authentication = { status: "zerodha_login_required" };
|
|
2151
|
+
const attempt = document.querySelector('[data-zerodha-login-page="true"] [data-operator-login-attempt]');
|
|
2152
|
+
if (!attempt) authentication = { status: "zerodha_login_required", nextAction: "zerodha-login" };
|
|
2153
|
+
else if (visible(attempt) && attempt.getAttribute("data-operator-login-status") === "awaiting_otp" && attempt.getAttribute("data-operator-login-submitting-otp") !== "true") {
|
|
2154
|
+
const attemptId = attempt.getAttribute("data-operator-login-attempt-id");
|
|
2155
|
+
if (attemptId) authentication = { status: "zerodha_login_required", nextAction: "zerodha-otp", attemptId };
|
|
2156
|
+
}
|
|
2157
|
+
}
|
|
2158
|
+
}
|
|
1876
2159
|
const controls2 = Array.from(
|
|
1877
2160
|
document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
|
|
1878
2161
|
).filter(visible).slice(0, 100).map((element, index) => {
|
|
@@ -1886,6 +2169,7 @@ async function inspectPage(page, mode, crawler) {
|
|
|
1886
2169
|
};
|
|
1887
2170
|
});
|
|
1888
2171
|
return {
|
|
2172
|
+
authentication,
|
|
1889
2173
|
title: document.title,
|
|
1890
2174
|
text: document.body?.innerText ?? "",
|
|
1891
2175
|
controls: controls2,
|
|
@@ -1899,6 +2183,7 @@ async function inspectPage(page, mode, crawler) {
|
|
|
1899
2183
|
const redact = (value) => redactSecrets(crawler?.controls().redact(value) ?? value);
|
|
1900
2184
|
const viewport = page.viewportSize?.() ?? result.viewport;
|
|
1901
2185
|
return {
|
|
2186
|
+
authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
|
|
1902
2187
|
url: safeUrl(redact(page.url())),
|
|
1903
2188
|
visibleState: {
|
|
1904
2189
|
title: redact(result.title).slice(0, 300),
|
|
@@ -1915,27 +2200,37 @@ async function inspectPage(page, mode, crawler) {
|
|
|
1915
2200
|
}
|
|
1916
2201
|
};
|
|
1917
2202
|
}
|
|
2203
|
+
var ACCESS_TOKEN_SCREENSHOT_MASK = '[data-lmt-sensitive="zerodha-access-token"]';
|
|
2204
|
+
async function screenshotMaskOptions(page) {
|
|
2205
|
+
const playwrightPage = page;
|
|
2206
|
+
const locator = playwrightPage.locator(ACCESS_TOKEN_SCREENSHOT_MASK);
|
|
2207
|
+
if (!await locator.count()) return {};
|
|
2208
|
+
return { mask: [locator], maskColor: "#000000" };
|
|
2209
|
+
}
|
|
1918
2210
|
async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, onTimeout) {
|
|
1919
2211
|
if (!page) return { outcome: "unavailable", reason: "No browser page was available." };
|
|
1920
2212
|
if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
|
|
1921
2213
|
const directory = artifactsDirectory(sessionId);
|
|
1922
2214
|
const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
|
|
1923
|
-
const screenshotPath =
|
|
2215
|
+
const screenshotPath = path5.resolve(directory, filename);
|
|
1924
2216
|
if (timeoutMs < 1) {
|
|
1925
2217
|
return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
|
|
1926
2218
|
}
|
|
1927
2219
|
try {
|
|
1928
|
-
await
|
|
2220
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
1929
2221
|
await chmod2(directory, 448);
|
|
1930
2222
|
await withDeadline(
|
|
1931
2223
|
(async () => {
|
|
1932
2224
|
await waitForRenderedPage(page);
|
|
1933
|
-
|
|
2225
|
+
const maskOptions = await screenshotMaskOptions(page);
|
|
2226
|
+
const options = {
|
|
1934
2227
|
path: screenshotPath,
|
|
1935
2228
|
fullPage,
|
|
1936
2229
|
animations: "disabled",
|
|
1937
|
-
timeout: timeoutMs
|
|
1938
|
-
|
|
2230
|
+
timeout: timeoutMs,
|
|
2231
|
+
...maskOptions
|
|
2232
|
+
};
|
|
2233
|
+
return fullPage ? captureFullContent(page, options) : page.screenshot(options);
|
|
1939
2234
|
})(),
|
|
1940
2235
|
timeoutMs,
|
|
1941
2236
|
onTimeout
|
|
@@ -1973,6 +2268,23 @@ async function runSessionOwner(options) {
|
|
|
1973
2268
|
await ensureSessionDirectory();
|
|
1974
2269
|
const socketPath = socketPathFor(options.sessionId);
|
|
1975
2270
|
const device = deviceContext(options.mode);
|
|
2271
|
+
const saved = options.persist ? await SavedSession.open({ environment: options.environment ?? "local", origin: options.origin, mode: options.mode }) : void 0;
|
|
2272
|
+
const storageState = saved?.load();
|
|
2273
|
+
let checkpointQueue = Promise.resolve();
|
|
2274
|
+
let checkpointError;
|
|
2275
|
+
function checkpoint() {
|
|
2276
|
+
if (!saved || !page) return Promise.resolve();
|
|
2277
|
+
const context = page.context();
|
|
2278
|
+
const save = async () => {
|
|
2279
|
+
const cookies = await context.cookies(options.origin);
|
|
2280
|
+
await saved.save({ cookies, origins: [] });
|
|
2281
|
+
};
|
|
2282
|
+
checkpointQueue = checkpointQueue.then(save, save);
|
|
2283
|
+
checkpointQueue.catch(() => {
|
|
2284
|
+
checkpointError = new SavedSessionError();
|
|
2285
|
+
});
|
|
2286
|
+
return checkpointQueue;
|
|
2287
|
+
}
|
|
1976
2288
|
let crawler;
|
|
1977
2289
|
let page;
|
|
1978
2290
|
let startupError;
|
|
@@ -1982,6 +2294,7 @@ async function runSessionOwner(options) {
|
|
|
1982
2294
|
let operationQueue = Promise.resolve();
|
|
1983
2295
|
let shutdownPromise;
|
|
1984
2296
|
let initializationPromise;
|
|
2297
|
+
let startupLease;
|
|
1985
2298
|
let crawlerClosePromise;
|
|
1986
2299
|
let expirationCleanup;
|
|
1987
2300
|
let forceClosePendingBrowser;
|
|
@@ -2034,7 +2347,12 @@ async function runSessionOwner(options) {
|
|
|
2034
2347
|
return;
|
|
2035
2348
|
}
|
|
2036
2349
|
const run = async () => {
|
|
2037
|
-
|
|
2350
|
+
let response;
|
|
2351
|
+
try {
|
|
2352
|
+
response = await handleRequest(request);
|
|
2353
|
+
} catch (error) {
|
|
2354
|
+
response = failureResult(request.action, error instanceof SavedSessionError ? "saved_session_unavailable" : "action_failed", "The browser action could not complete safely. Inspect before retrying.", options.sessionId);
|
|
2355
|
+
}
|
|
2038
2356
|
socket.end(`${JSON.stringify(response)}
|
|
2039
2357
|
`);
|
|
2040
2358
|
if (closing) socket.once("close", () => void shutdown());
|
|
@@ -2048,7 +2366,7 @@ async function runSessionOwner(options) {
|
|
|
2048
2366
|
baseUrl: options.origin,
|
|
2049
2367
|
headless: options.headless,
|
|
2050
2368
|
timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
|
|
2051
|
-
contextOptions: device.contextOptions,
|
|
2369
|
+
contextOptions: { ...device.contextOptions, ...storageState ? { storageState } : {} },
|
|
2052
2370
|
launch: () => {
|
|
2053
2371
|
const onServerReady = (forceClose) => {
|
|
2054
2372
|
forceClosePendingBrowser = forceClose;
|
|
@@ -2063,11 +2381,23 @@ async function runSessionOwner(options) {
|
|
|
2063
2381
|
}
|
|
2064
2382
|
});
|
|
2065
2383
|
page = crawler.getPage();
|
|
2384
|
+
if (saved) {
|
|
2385
|
+
page.context().on("requestfinished", (request) => {
|
|
2386
|
+
if (new URL(request.url()).origin !== options.origin) return;
|
|
2387
|
+
void request.response().then(async (response) => {
|
|
2388
|
+
const headers = await response?.allHeaders();
|
|
2389
|
+
if (headers?.["set-cookie"] && !closing) await checkpoint();
|
|
2390
|
+
}).catch(() => {
|
|
2391
|
+
checkpointError = new SavedSessionError();
|
|
2392
|
+
});
|
|
2393
|
+
});
|
|
2394
|
+
}
|
|
2066
2395
|
if (closing) {
|
|
2067
2396
|
await closeCrawler();
|
|
2068
2397
|
return;
|
|
2069
2398
|
}
|
|
2070
2399
|
await crawler.openHomePage();
|
|
2400
|
+
await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
|
|
2071
2401
|
} catch (error) {
|
|
2072
2402
|
startupError = error;
|
|
2073
2403
|
}
|
|
@@ -2076,17 +2406,16 @@ async function runSessionOwner(options) {
|
|
|
2076
2406
|
if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
|
|
2077
2407
|
return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
|
|
2078
2408
|
}
|
|
2409
|
+
if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start to restore the saved session.", options.sessionId);
|
|
2079
2410
|
const deadlineAt = request.deadlineAt;
|
|
2080
2411
|
if (remainingMs(deadlineAt) === 0) {
|
|
2081
2412
|
return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
|
|
2082
2413
|
}
|
|
2083
2414
|
if (request.action === "start" && startComplete) {
|
|
2084
|
-
return
|
|
2085
|
-
}
|
|
2086
|
-
if (!startComplete && request.action !== "start") {
|
|
2087
|
-
return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
|
|
2415
|
+
return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
|
|
2088
2416
|
}
|
|
2089
2417
|
if (request.action === "stop") {
|
|
2418
|
+
await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
|
|
2090
2419
|
closing = true;
|
|
2091
2420
|
abortOnShutdown = true;
|
|
2092
2421
|
try {
|
|
@@ -2097,6 +2426,7 @@ async function runSessionOwner(options) {
|
|
|
2097
2426
|
sessionId: options.sessionId,
|
|
2098
2427
|
action: "stop",
|
|
2099
2428
|
outcome: "success",
|
|
2429
|
+
authentication: { status: "unknown" },
|
|
2100
2430
|
url: null,
|
|
2101
2431
|
visibleState: null,
|
|
2102
2432
|
device: null,
|
|
@@ -2106,7 +2436,12 @@ async function runSessionOwner(options) {
|
|
|
2106
2436
|
return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
|
|
2107
2437
|
}
|
|
2108
2438
|
}
|
|
2439
|
+
if (!startComplete && request.action !== "start") {
|
|
2440
|
+
return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
|
|
2441
|
+
}
|
|
2442
|
+
if (checkpointError) throw checkpointError;
|
|
2109
2443
|
if (!startComplete) {
|
|
2444
|
+
clearTimeout(startupLease);
|
|
2110
2445
|
initializationPromise ??= initialize(deadlineAt);
|
|
2111
2446
|
try {
|
|
2112
2447
|
await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
|
|
@@ -2135,6 +2470,7 @@ async function runSessionOwner(options) {
|
|
|
2135
2470
|
sessionId: options.sessionId,
|
|
2136
2471
|
action: "start",
|
|
2137
2472
|
outcome: actionError2 ? "failure" : "success",
|
|
2473
|
+
authentication: inspected2?.authentication ?? { status: "unknown" },
|
|
2138
2474
|
url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
2139
2475
|
visibleState: inspected2?.visibleState ?? null,
|
|
2140
2476
|
device: inspected2?.device ?? null,
|
|
@@ -2161,12 +2497,14 @@ async function runSessionOwner(options) {
|
|
|
2161
2497
|
}
|
|
2162
2498
|
let guidedError;
|
|
2163
2499
|
let submitted = false;
|
|
2164
|
-
const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) /
|
|
2165
|
-
|
|
2166
|
-
const
|
|
2167
|
-
|
|
2168
|
-
|
|
2500
|
+
const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) / 2));
|
|
2501
|
+
const pendingActions = /* @__PURE__ */ new Set();
|
|
2502
|
+
const runAction = (operation, timeoutMs) => {
|
|
2503
|
+
pendingActions.add(operation);
|
|
2504
|
+
operation.then(() => pendingActions.delete(operation), () => pendingActions.delete(operation));
|
|
2505
|
+
return withDeadline(operation, timeoutMs);
|
|
2169
2506
|
};
|
|
2507
|
+
const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
|
|
2170
2508
|
page.setDefaultTimeout?.(actionTimeout());
|
|
2171
2509
|
page.setDefaultNavigationTimeout?.(actionTimeout());
|
|
2172
2510
|
let loginAttempt;
|
|
@@ -2175,27 +2513,28 @@ async function runSessionOwner(options) {
|
|
|
2175
2513
|
};
|
|
2176
2514
|
const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
|
|
2177
2515
|
try {
|
|
2178
|
-
if (request.action === "operator-login") await
|
|
2179
|
-
if (request.action === "operator-logout") await
|
|
2180
|
-
if (request.action === "zerodha-login") loginAttempt = await
|
|
2516
|
+
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());
|
|
2517
|
+
if (request.action === "operator-logout") await runAction(operatorLogout(page, actionTimeout(), onSubmit), actionTimeout());
|
|
2518
|
+
if (request.action === "zerodha-login") loginAttempt = await runAction(zerodha.startLoginAttempt(onSubmit), actionTimeout());
|
|
2181
2519
|
if (request.action === "zerodha-otp") {
|
|
2182
2520
|
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.");
|
|
2183
2521
|
crawler.controls().registerSecrets(request.otp);
|
|
2184
|
-
const result = await
|
|
2522
|
+
const result = await runAction(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout());
|
|
2185
2523
|
if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
|
|
2186
2524
|
}
|
|
2187
|
-
if (request.action === "zerodha-logout") await
|
|
2525
|
+
if (request.action === "zerodha-logout") await runAction(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout());
|
|
2188
2526
|
} catch (error2) {
|
|
2189
2527
|
guidedError = error2;
|
|
2190
2528
|
}
|
|
2191
2529
|
let inspected = null;
|
|
2530
|
+
let controlResult;
|
|
2192
2531
|
let actionError;
|
|
2193
2532
|
try {
|
|
2194
2533
|
if (isControlAction(request)) {
|
|
2195
2534
|
const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
|
|
2196
|
-
await
|
|
2535
|
+
controlResult = await runAction(crawler.controls().act(request, budget, () => {
|
|
2197
2536
|
submitted = true;
|
|
2198
|
-
}), budget
|
|
2537
|
+
}), budget);
|
|
2199
2538
|
}
|
|
2200
2539
|
} catch (error2) {
|
|
2201
2540
|
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;
|
|
@@ -2203,6 +2542,25 @@ async function runSessionOwner(options) {
|
|
|
2203
2542
|
await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
|
|
2204
2543
|
}
|
|
2205
2544
|
}
|
|
2545
|
+
if (pendingActions.size || failureCode(actionError ?? guidedError) === "deadline_exceeded") {
|
|
2546
|
+
try {
|
|
2547
|
+
await withDeadline((async () => {
|
|
2548
|
+
await crawler.controls().stopLoading();
|
|
2549
|
+
await Promise.allSettled([...pendingActions]);
|
|
2550
|
+
await page.evaluate?.(() => document.readyState);
|
|
2551
|
+
})(), Math.min(500, Math.max(1, remainingMs(deadlineAt))), expireSession);
|
|
2552
|
+
} catch {
|
|
2553
|
+
expireSession();
|
|
2554
|
+
return { ...failureResult(request.action, submitted ? "action_uncertain" : "deadline_exceeded", "The browser could not safely recover from the action deadline. Start to restore the saved session; do not replay an uncertain action.", options.sessionId), outcome: submitted ? "uncertain" : "failure" };
|
|
2555
|
+
}
|
|
2556
|
+
}
|
|
2557
|
+
if (saved) {
|
|
2558
|
+
try {
|
|
2559
|
+
await withDeadline(checkpoint(), Math.max(1, remainingMs(deadlineAt)), expireSession);
|
|
2560
|
+
} catch (error2) {
|
|
2561
|
+
actionError ??= error2;
|
|
2562
|
+
}
|
|
2563
|
+
}
|
|
2206
2564
|
try {
|
|
2207
2565
|
inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
|
|
2208
2566
|
} catch (error2) {
|
|
@@ -2228,13 +2586,14 @@ async function runSessionOwner(options) {
|
|
|
2228
2586
|
}
|
|
2229
2587
|
}
|
|
2230
2588
|
}
|
|
2231
|
-
if (actionTimedOut) closing = true;
|
|
2232
2589
|
const error = guidedError ?? actionError ?? closeError;
|
|
2233
2590
|
return {
|
|
2234
2591
|
version: RESULT_VERSION,
|
|
2235
2592
|
sessionId: options.sessionId,
|
|
2236
2593
|
action: request.action,
|
|
2237
2594
|
outcome: error instanceof ControlError && error.code === "action_uncertain" ? "uncertain" : error ? submitted && !(error instanceof OperatorActionError) && !(error instanceof LoginAttemptError) ? "uncertain" : "failure" : "success",
|
|
2595
|
+
authentication: inspected?.authentication ?? { status: "unknown" },
|
|
2596
|
+
...!actionError ? controlResult : {},
|
|
2238
2597
|
...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
|
|
2239
2598
|
url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
2240
2599
|
visibleState: inspected?.visibleState ?? null,
|
|
@@ -2246,6 +2605,7 @@ async function runSessionOwner(options) {
|
|
|
2246
2605
|
function shutdown() {
|
|
2247
2606
|
if (!closing) return Promise.resolve();
|
|
2248
2607
|
if (shutdownPromise) return shutdownPromise;
|
|
2608
|
+
clearTimeout(startupLease);
|
|
2249
2609
|
shutdownPromise = (async () => {
|
|
2250
2610
|
for (const socket of sockets) socket.destroy();
|
|
2251
2611
|
await unlink2(socketPath).catch(() => void 0);
|
|
@@ -2272,6 +2632,7 @@ async function runSessionOwner(options) {
|
|
|
2272
2632
|
}
|
|
2273
2633
|
if (crawler) {
|
|
2274
2634
|
try {
|
|
2635
|
+
await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
|
|
2275
2636
|
await closeCrawler();
|
|
2276
2637
|
} catch {
|
|
2277
2638
|
await crawler.forceClose?.().catch(() => void 0);
|
|
@@ -2279,6 +2640,8 @@ async function runSessionOwner(options) {
|
|
|
2279
2640
|
} else if (forceClosePendingBrowser) {
|
|
2280
2641
|
await forceCloseBrowser().catch(() => void 0);
|
|
2281
2642
|
}
|
|
2643
|
+
await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
|
|
2644
|
+
saved?.close();
|
|
2282
2645
|
process.removeListener("SIGTERM", onSignal);
|
|
2283
2646
|
process.removeListener("SIGINT", onSignal);
|
|
2284
2647
|
})();
|
|
@@ -2291,6 +2654,11 @@ async function runSessionOwner(options) {
|
|
|
2291
2654
|
void chmod2(socketPath, 384).then(resolve, reject);
|
|
2292
2655
|
});
|
|
2293
2656
|
});
|
|
2657
|
+
startupLease = setTimeout(() => {
|
|
2658
|
+
if (initializationPromise) return;
|
|
2659
|
+
expireSession();
|
|
2660
|
+
void shutdown();
|
|
2661
|
+
}, options.timeoutMs);
|
|
2294
2662
|
const onSignal = () => {
|
|
2295
2663
|
closing = true;
|
|
2296
2664
|
void shutdown();
|
|
@@ -2302,7 +2670,7 @@ async function wait(milliseconds) {
|
|
|
2302
2670
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2303
2671
|
}
|
|
2304
2672
|
function assertOwnedSocket(socketPath) {
|
|
2305
|
-
return
|
|
2673
|
+
return lstat3(socketPath).then((entry) => {
|
|
2306
2674
|
const uid = process.getuid?.();
|
|
2307
2675
|
if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
|
|
2308
2676
|
throw new Error("The lmt-web session socket is not owned by the invoking user.");
|
|
@@ -2383,10 +2751,14 @@ var HELP = `lmt-web \u2014 browser interaction with Live Market Terminal Web
|
|
|
2383
2751
|
|
|
2384
2752
|
Usage:
|
|
2385
2753
|
lmt-web install-browser [--with-deps] [--dry-run]
|
|
2386
|
-
lmt-web start --env local|dev [--device mobile|desktop] [--headed] [--timeout 30s]
|
|
2754
|
+
lmt-web start --env local|dev [--device mobile|desktop] [--headed] [--fresh] [--timeout 30s]
|
|
2387
2755
|
lmt-web inspect --session ID [--full-page] [--timeout 30s]
|
|
2388
2756
|
lmt-web click --session ID --ref REF
|
|
2389
2757
|
lmt-web fill --session ID --ref REF --value TEXT [--secret]
|
|
2758
|
+
lmt-web read --session ID --ref REF
|
|
2759
|
+
lmt-web clipboard --session ID
|
|
2760
|
+
lmt-web check --session ID --ref REF --stdin
|
|
2761
|
+
lmt-web clipboard-check --session ID --ref REF
|
|
2390
2762
|
lmt-web select --session ID --ref REF --value OPTION
|
|
2391
2763
|
lmt-web scroll --session ID [--ref REF] [--x PIXELS] [--y PIXELS]
|
|
2392
2764
|
lmt-web navigate --session ID --url PATH_OR_URL
|
|
@@ -2399,7 +2771,8 @@ Usage:
|
|
|
2399
2771
|
lmt-web close --session ID [--full-page] [--timeout 30s]
|
|
2400
2772
|
lmt-web stop --session ID [--timeout 30s]
|
|
2401
2773
|
|
|
2402
|
-
Start
|
|
2774
|
+
Start restores the saved session or reuses its live browser. --fresh is temporary.
|
|
2775
|
+
Mobile emulation
|
|
2403
2776
|
and headless mode are the defaults. Inspect and close require the returned session ID.
|
|
2404
2777
|
Stop terminates the session's browser and owner without capturing a final screenshot.
|
|
2405
2778
|
Each command writes one versioned JSON result to stdout; diagnostics go to stderr.
|
|
@@ -2465,6 +2838,7 @@ async function parseCommand(argv) {
|
|
|
2465
2838
|
environment: values.get("--env") ?? "",
|
|
2466
2839
|
mode,
|
|
2467
2840
|
headless: !flags.has("--headed"),
|
|
2841
|
+
fresh: flags.has("--fresh"),
|
|
2468
2842
|
timeoutMs
|
|
2469
2843
|
};
|
|
2470
2844
|
}
|
|
@@ -2474,7 +2848,7 @@ async function parseCommand(argv) {
|
|
|
2474
2848
|
}
|
|
2475
2849
|
let payload;
|
|
2476
2850
|
try {
|
|
2477
|
-
payload = await definition.build({ values, flags, readOtp: () => readOtp(timeoutMs) });
|
|
2851
|
+
payload = await definition.build({ values, flags, readOtp: () => readOtp(timeoutMs), readInput: () => readInput(timeoutMs) });
|
|
2478
2852
|
} catch (error) {
|
|
2479
2853
|
throw new InvocationError(error instanceof Error ? error.message : "The action input could not be read.", error instanceof InvocationError ? error.code : "action_failed", action);
|
|
2480
2854
|
}
|
|
@@ -2487,6 +2861,23 @@ function emit(result, diagnostic) {
|
|
|
2487
2861
|
if (diagnostic) process.stderr.write(`${diagnostic}
|
|
2488
2862
|
`);
|
|
2489
2863
|
}
|
|
2864
|
+
async function readInput(timeoutMs) {
|
|
2865
|
+
if (process.stdin.isTTY) throw new InvocationError("Pipe the expected value on stdin.");
|
|
2866
|
+
let input = "";
|
|
2867
|
+
const timer = setTimeout(() => process.stdin.destroy(new Error("Input deadline exceeded.")), timeoutMs);
|
|
2868
|
+
try {
|
|
2869
|
+
for await (const chunk of process.stdin) {
|
|
2870
|
+
input += chunk.toString();
|
|
2871
|
+
if (Buffer.byteLength(input) > 16384) throw new InvocationError("Expected value is too large.");
|
|
2872
|
+
}
|
|
2873
|
+
return input.replace(/\r?\n$/, "");
|
|
2874
|
+
} catch {
|
|
2875
|
+
throw new InvocationError("Expected value could not be read from stdin.");
|
|
2876
|
+
} finally {
|
|
2877
|
+
input = "";
|
|
2878
|
+
clearTimeout(timer);
|
|
2879
|
+
}
|
|
2880
|
+
}
|
|
2490
2881
|
async function readOtp(timeoutMs) {
|
|
2491
2882
|
let input = "";
|
|
2492
2883
|
const timer = setTimeout(() => process.stdin.destroy(new Error("OTP input exceeded its deadline.")), timeoutMs);
|
|
@@ -2511,47 +2902,69 @@ async function startSession(command) {
|
|
|
2511
2902
|
throw new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
|
|
2512
2903
|
}
|
|
2513
2904
|
await ensureSessionDirectory();
|
|
2514
|
-
const
|
|
2515
|
-
const
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2905
|
+
const deadlineAt = Date.now() + command.timeoutMs;
|
|
2906
|
+
const store = command.fresh ? void 0 : await SavedSession.open({ ...target, mode: command.mode });
|
|
2907
|
+
let spawned;
|
|
2908
|
+
try {
|
|
2909
|
+
store?.load();
|
|
2910
|
+
const selectOwner = () => {
|
|
2911
|
+
const existing = store?.owner();
|
|
2912
|
+
if (existing) {
|
|
2913
|
+
if (existing.headless !== command.headless) throw new InvocationError("The saved session is already live with different visibility. Close it before changing --headed, or use --fresh.");
|
|
2914
|
+
return existing;
|
|
2915
|
+
}
|
|
2916
|
+
const sessionId = createSessionId();
|
|
2917
|
+
const scriptPath = path6.resolve(process.argv[1] || fileURLToPath3(import.meta.url));
|
|
2918
|
+
const child = spawn2(process.execPath, [
|
|
2919
|
+
scriptPath,
|
|
2920
|
+
"__owner",
|
|
2921
|
+
sessionId,
|
|
2922
|
+
target.origin,
|
|
2923
|
+
command.mode,
|
|
2924
|
+
command.headless ? "headless" : "headed",
|
|
2925
|
+
String(command.timeoutMs),
|
|
2926
|
+
target.environment,
|
|
2927
|
+
command.fresh ? "fresh" : "saved"
|
|
2928
|
+
], { detached: true, stdio: "ignore" });
|
|
2929
|
+
spawned = child;
|
|
2930
|
+
child.on("error", () => void 0);
|
|
2931
|
+
child.unref();
|
|
2932
|
+
if (!child.pid) throw new Error("Could not start the browser owner.");
|
|
2933
|
+
const owner = { sessionId, pid: child.pid, headless: command.headless };
|
|
2934
|
+
store?.setOwner(owner);
|
|
2935
|
+
return owner;
|
|
2936
|
+
};
|
|
2937
|
+
while (Date.now() < deadlineAt) {
|
|
2938
|
+
const owner = store ? await store.reserve(deadlineAt, selectOwner) : selectOwner();
|
|
2939
|
+
const result = await requestSession({ sessionId: owner.sessionId, action: "start", timeoutMs: Math.max(1, deadlineAt - Date.now()) }, {
|
|
2940
|
+
waitForSocket: true,
|
|
2941
|
+
ownerAlive: () => {
|
|
2942
|
+
try {
|
|
2943
|
+
process.kill(owner.pid, 0);
|
|
2944
|
+
return true;
|
|
2945
|
+
} catch {
|
|
2946
|
+
return false;
|
|
2947
|
+
}
|
|
2948
|
+
}
|
|
2949
|
+
});
|
|
2950
|
+
if (!store || !["session_not_found", "session_unavailable", "browser_start_failed"].includes(result.error?.code ?? "")) return result;
|
|
2951
|
+
await new Promise((resolve) => setTimeout(resolve, 40));
|
|
2531
2952
|
}
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
2539
|
-
});
|
|
2540
|
-
child.unref();
|
|
2541
|
-
const result = await requestSession({ sessionId, action: "start", timeoutMs: command.timeoutMs }, {
|
|
2542
|
-
waitForSocket: true,
|
|
2543
|
-
ownerAlive: () => ownerAlive
|
|
2544
|
-
});
|
|
2545
|
-
if (result.outcome === "failure" && result.error?.code === "deadline_exceeded") {
|
|
2546
|
-
try {
|
|
2547
|
-
process.kill(child.pid, "SIGTERM");
|
|
2548
|
-
} catch {
|
|
2953
|
+
return failureResult("start", "deadline_exceeded", "The saved browser owner did not become available before the deadline.");
|
|
2954
|
+
} catch (error) {
|
|
2955
|
+
if (spawned?.pid) {
|
|
2956
|
+
try {
|
|
2957
|
+
process.kill(spawned.pid, "SIGTERM");
|
|
2958
|
+
} catch {
|
|
2959
|
+
}
|
|
2549
2960
|
}
|
|
2961
|
+
throw error;
|
|
2962
|
+
} finally {
|
|
2963
|
+
store?.close();
|
|
2550
2964
|
}
|
|
2551
|
-
return result;
|
|
2552
2965
|
}
|
|
2553
2966
|
async function runOwner(argv) {
|
|
2554
|
-
const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment] = argv;
|
|
2967
|
+
const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment, persistence] = argv;
|
|
2555
2968
|
if (!isSessionId(sessionId) || !origin || modeValue !== "mobile" && modeValue !== "desktop" || headlessValue !== "headless" && headlessValue !== "headed" || !timeoutValue || !/^\d+$/.test(timeoutValue)) {
|
|
2556
2969
|
process.exitCode = 2;
|
|
2557
2970
|
return;
|
|
@@ -2562,7 +2975,8 @@ async function runOwner(argv) {
|
|
|
2562
2975
|
environment: environment === "dev" ? "dev" : "local",
|
|
2563
2976
|
mode: modeValue,
|
|
2564
2977
|
headless: headlessValue === "headless",
|
|
2565
|
-
timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS)
|
|
2978
|
+
timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS),
|
|
2979
|
+
persist: persistence === "saved"
|
|
2566
2980
|
});
|
|
2567
2981
|
}
|
|
2568
2982
|
async function installBrowser(options) {
|
|
@@ -2575,7 +2989,7 @@ async function installBrowser(options) {
|
|
|
2575
2989
|
}
|
|
2576
2990
|
try {
|
|
2577
2991
|
const require2 = createRequire(import.meta.url);
|
|
2578
|
-
const browserCli =
|
|
2992
|
+
const browserCli = path6.join(path6.dirname(require2.resolve("playwright/package.json")), "cli.js");
|
|
2579
2993
|
const code = await new Promise((resolve, reject) => {
|
|
2580
2994
|
const child = spawn2(process.execPath, [browserCli, "install", ...options, "chromium"], {
|
|
2581
2995
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -2671,7 +3085,7 @@ async function runCli(argv) {
|
|
|
2671
3085
|
}
|
|
2672
3086
|
} catch (error) {
|
|
2673
3087
|
const action = command?.action ?? (error instanceof InvocationError ? error.action : void 0) ?? (argv[0] === "inspect" || argv[0] === "close" ? argv[0] : "start");
|
|
2674
|
-
const code = error instanceof InvocationError ? error.code : "action_failed";
|
|
3088
|
+
const code = error instanceof SavedSessionError ? "saved_session_unavailable" : error instanceof InvocationError ? error.code : "action_failed";
|
|
2675
3089
|
const message = error instanceof Error ? error.message : "The lmt-web command failed.";
|
|
2676
3090
|
const result = failureResult(action, code, message);
|
|
2677
3091
|
emit(result, `${code}: ${message}`);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ai-digital-factory/lmt-web-cli",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.4",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"bin": {
|
|
6
6
|
"lmt-web": "dist/index.js"
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
},
|
|
11
11
|
"description": "Standalone browser session CLI for Live Market Terminal Web.",
|
|
12
12
|
"engines": {
|
|
13
|
-
"node": ">=22"
|
|
13
|
+
"node": ">=22.13"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"dist",
|