@ai-digital-factory/lmt-web-cli 0.1.3 → 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 +74 -16
- package/dist/index.js +338 -77
- 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.
|
|
@@ -133,18 +135,70 @@ the page or taking a final screenshot. It also works after the browser window ha
|
|
|
133
135
|
been closed manually. Supply the session ID returned by `start`; other sessions
|
|
134
136
|
remain running. Like `close`, it does not log out or stop Broker activity.
|
|
135
137
|
|
|
136
|
-
`start`
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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.
|
|
144
197
|
|
|
145
198
|
Each command writes one versioned JSON result to stdout and diagnostics to
|
|
146
199
|
stderr. Results contain the action outcome, current URL, visible page text and
|
|
147
|
-
controls, emulated device and actual viewport, and screenshot
|
|
200
|
+
controls, `authentication`, emulated device and actual viewport, and screenshot
|
|
201
|
+
outcome. Images
|
|
148
202
|
are written to unique absolute paths in an owner-only temporary directory;
|
|
149
203
|
screenshots solid-mask only Zerodha access-token value fields marked with
|
|
150
204
|
`data-lmt-sensitive="zerodha-access-token"`; other controls and metadata stay visible.
|
|
@@ -155,8 +209,11 @@ session-loading or busy indicators, fonts, visible images, and finite animations
|
|
|
155
209
|
to finish. The page must render text, controls, or visual content; an empty app
|
|
156
210
|
root is not ready. Pages that remain empty reach the command deadline without a
|
|
157
211
|
captured screenshot. Infinite animations do not block capture. `--timeout` sets one deadline for the command's browser action and
|
|
158
|
-
screenshot, from `1ms` through `10m`.
|
|
159
|
-
|
|
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.
|
|
160
217
|
The JSON `error.code` is machine-readable:
|
|
161
218
|
|
|
162
219
|
| Code | Exit | Meaning |
|
|
@@ -165,7 +222,8 @@ The JSON `error.code` is machine-readable:
|
|
|
165
222
|
| `browser_unavailable` | 1 | Install Chromium with `lmt-web install-browser`, then retry. |
|
|
166
223
|
| `browser_start_failed` | 1 | The local browser owner exited before its session became ready. |
|
|
167
224
|
| `service_unavailable` | 1 | The selected Web URL could not be reached; start/check that service. |
|
|
168
|
-
| `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. |
|
|
169
227
|
| `deadline_exceeded`, `action_failed` | 1 | The action failed or exceeded its finite deadline. |
|
|
170
228
|
|
|
171
229
|
Screenshots cover the viewport by default. `inspect --full-page` and
|
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" }) },
|
|
@@ -548,14 +689,14 @@ function isControlAction(action) {
|
|
|
548
689
|
|
|
549
690
|
// src/config.ts
|
|
550
691
|
import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
|
|
551
|
-
import
|
|
692
|
+
import path4 from "node:path";
|
|
552
693
|
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
553
|
-
var repositoryRoot =
|
|
554
|
-
|
|
694
|
+
var repositoryRoot = path4.resolve(
|
|
695
|
+
path4.dirname(fileURLToPath2(import.meta.url)),
|
|
555
696
|
"../../../../"
|
|
556
697
|
);
|
|
557
698
|
var packagedConfig = fileURLToPath2(new URL("../lmt-web.config.json", import.meta.url));
|
|
558
|
-
var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig :
|
|
699
|
+
var DEFAULT_CONFIG_PATH2 = existsSync2(packagedConfig) ? packagedConfig : path4.join(repositoryRoot, "lmt.config.json");
|
|
559
700
|
function canonicalOrigin(value, environment) {
|
|
560
701
|
if (typeof value !== "string" || !value.trim()) {
|
|
561
702
|
throw new Error(`lmt-web.config.json does not contain the ${environment} Web URL.`);
|
|
@@ -605,6 +746,7 @@ function failureResult(action, code, message, sessionId = null) {
|
|
|
605
746
|
sessionId,
|
|
606
747
|
action,
|
|
607
748
|
outcome: "failure",
|
|
749
|
+
authentication: { status: "unknown" },
|
|
608
750
|
url: null,
|
|
609
751
|
visibleState: null,
|
|
610
752
|
device: null,
|
|
@@ -615,10 +757,10 @@ function failureResult(action, code, message, sessionId = null) {
|
|
|
615
757
|
|
|
616
758
|
// src/session.ts
|
|
617
759
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
618
|
-
import { chmod as chmod2, lstat as
|
|
760
|
+
import { chmod as chmod2, lstat as lstat3, mkdir as mkdir3, unlink as unlink2 } from "node:fs/promises";
|
|
619
761
|
import net from "node:net";
|
|
620
|
-
import
|
|
621
|
-
import
|
|
762
|
+
import os2 from "node:os";
|
|
763
|
+
import path5 from "node:path";
|
|
622
764
|
import { chromium as chromium2 } from "playwright";
|
|
623
765
|
|
|
624
766
|
// ../../packages/crawler/src/full-content.ts
|
|
@@ -1829,7 +1971,7 @@ async function operatorLogout(page, timeoutMs, onSubmit = () => void 0) {
|
|
|
1829
1971
|
}
|
|
1830
1972
|
|
|
1831
1973
|
// src/session.ts
|
|
1832
|
-
var SOCKET_DIRECTORY =
|
|
1974
|
+
var SOCKET_DIRECTORY = path5.join(os2.tmpdir(), `lmt-web-${process.getuid?.() ?? "user"}`);
|
|
1833
1975
|
var SESSION_ID_PATTERN = /^[a-f0-9]{32}$/;
|
|
1834
1976
|
var OWNER_RESPONSE_GRACE_MS = 1e3;
|
|
1835
1977
|
var OWNER_CLEANUP_GRACE_MS = 2e3;
|
|
@@ -1844,19 +1986,19 @@ async function ensureSessionDirectory() {
|
|
|
1844
1986
|
if (process.platform === "win32") {
|
|
1845
1987
|
throw new Error("lmt-web session sockets are supported on macOS and Linux.");
|
|
1846
1988
|
}
|
|
1847
|
-
await
|
|
1989
|
+
await mkdir3(SOCKET_DIRECTORY, { recursive: true, mode: 448 });
|
|
1848
1990
|
await chmod2(SOCKET_DIRECTORY, 448);
|
|
1849
|
-
const directory = await
|
|
1991
|
+
const directory = await lstat3(SOCKET_DIRECTORY);
|
|
1850
1992
|
const uid = process.getuid?.();
|
|
1851
1993
|
if (!directory.isDirectory() || uid !== void 0 && directory.uid !== uid) {
|
|
1852
1994
|
throw new Error("The lmt-web session directory is not owned by the invoking user.");
|
|
1853
1995
|
}
|
|
1854
1996
|
}
|
|
1855
1997
|
function socketPathFor(sessionId) {
|
|
1856
|
-
return
|
|
1998
|
+
return path5.join(SOCKET_DIRECTORY, `${sessionId}.sock`);
|
|
1857
1999
|
}
|
|
1858
2000
|
function artifactsDirectory(sessionId) {
|
|
1859
|
-
return
|
|
2001
|
+
return path5.join(SOCKET_DIRECTORY, "artifacts", sessionId);
|
|
1860
2002
|
}
|
|
1861
2003
|
function redactSecrets(value) {
|
|
1862
2004
|
return value.replace(
|
|
@@ -1874,6 +2016,7 @@ function safeUrl(value) {
|
|
|
1874
2016
|
}
|
|
1875
2017
|
}
|
|
1876
2018
|
function failureCode(error) {
|
|
2019
|
+
if (error instanceof SavedSessionError) return "saved_session_unavailable";
|
|
1877
2020
|
if (error instanceof ControlError) return error.code;
|
|
1878
2021
|
const message = error instanceof Error ? error.message : "";
|
|
1879
2022
|
if (/timed out|timeout|deadline/i.test(message)) return "deadline_exceeded";
|
|
@@ -1993,6 +2136,26 @@ async function inspectPage(page, mode, crawler) {
|
|
|
1993
2136
|
const style = getComputedStyle(element);
|
|
1994
2137
|
return rect.width > 0 && rect.height > 0 && style.visibility !== "hidden" && style.display !== "none";
|
|
1995
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
|
+
}
|
|
1996
2159
|
const controls2 = Array.from(
|
|
1997
2160
|
document.querySelectorAll("button, a[href], input, select, textarea, [role=button]")
|
|
1998
2161
|
).filter(visible).slice(0, 100).map((element, index) => {
|
|
@@ -2006,6 +2169,7 @@ async function inspectPage(page, mode, crawler) {
|
|
|
2006
2169
|
};
|
|
2007
2170
|
});
|
|
2008
2171
|
return {
|
|
2172
|
+
authentication,
|
|
2009
2173
|
title: document.title,
|
|
2010
2174
|
text: document.body?.innerText ?? "",
|
|
2011
2175
|
controls: controls2,
|
|
@@ -2019,6 +2183,7 @@ async function inspectPage(page, mode, crawler) {
|
|
|
2019
2183
|
const redact = (value) => redactSecrets(crawler?.controls().redact(value) ?? value);
|
|
2020
2184
|
const viewport = page.viewportSize?.() ?? result.viewport;
|
|
2021
2185
|
return {
|
|
2186
|
+
authentication: "attemptId" in result.authentication ? { ...result.authentication, attemptId: redact(result.authentication.attemptId) } : result.authentication,
|
|
2022
2187
|
url: safeUrl(redact(page.url())),
|
|
2023
2188
|
visibleState: {
|
|
2024
2189
|
title: redact(result.title).slice(0, 300),
|
|
@@ -2047,12 +2212,12 @@ async function captureScreenshot(page, sessionId, action, fullPage, timeoutMs, o
|
|
|
2047
2212
|
if (!page.screenshot) return { outcome: "failed", error: "The browser does not support screenshots." };
|
|
2048
2213
|
const directory = artifactsDirectory(sessionId);
|
|
2049
2214
|
const filename = `${Date.now()}-${action}-${randomBytes2(4).toString("hex")}.png`;
|
|
2050
|
-
const screenshotPath =
|
|
2215
|
+
const screenshotPath = path5.resolve(directory, filename);
|
|
2051
2216
|
if (timeoutMs < 1) {
|
|
2052
2217
|
return { outcome: "failed", error: "The action deadline elapsed before screenshot capture." };
|
|
2053
2218
|
}
|
|
2054
2219
|
try {
|
|
2055
|
-
await
|
|
2220
|
+
await mkdir3(directory, { recursive: true, mode: 448 });
|
|
2056
2221
|
await chmod2(directory, 448);
|
|
2057
2222
|
await withDeadline(
|
|
2058
2223
|
(async () => {
|
|
@@ -2103,6 +2268,23 @@ async function runSessionOwner(options) {
|
|
|
2103
2268
|
await ensureSessionDirectory();
|
|
2104
2269
|
const socketPath = socketPathFor(options.sessionId);
|
|
2105
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
|
+
}
|
|
2106
2288
|
let crawler;
|
|
2107
2289
|
let page;
|
|
2108
2290
|
let startupError;
|
|
@@ -2112,6 +2294,7 @@ async function runSessionOwner(options) {
|
|
|
2112
2294
|
let operationQueue = Promise.resolve();
|
|
2113
2295
|
let shutdownPromise;
|
|
2114
2296
|
let initializationPromise;
|
|
2297
|
+
let startupLease;
|
|
2115
2298
|
let crawlerClosePromise;
|
|
2116
2299
|
let expirationCleanup;
|
|
2117
2300
|
let forceClosePendingBrowser;
|
|
@@ -2164,7 +2347,12 @@ async function runSessionOwner(options) {
|
|
|
2164
2347
|
return;
|
|
2165
2348
|
}
|
|
2166
2349
|
const run = async () => {
|
|
2167
|
-
|
|
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
|
+
}
|
|
2168
2356
|
socket.end(`${JSON.stringify(response)}
|
|
2169
2357
|
`);
|
|
2170
2358
|
if (closing) socket.once("close", () => void shutdown());
|
|
@@ -2178,7 +2366,7 @@ async function runSessionOwner(options) {
|
|
|
2178
2366
|
baseUrl: options.origin,
|
|
2179
2367
|
headless: options.headless,
|
|
2180
2368
|
timeoutMs: Math.min(options.timeoutMs, Math.max(1, remainingMs(deadlineAt))),
|
|
2181
|
-
contextOptions: device.contextOptions,
|
|
2369
|
+
contextOptions: { ...device.contextOptions, ...storageState ? { storageState } : {} },
|
|
2182
2370
|
launch: () => {
|
|
2183
2371
|
const onServerReady = (forceClose) => {
|
|
2184
2372
|
forceClosePendingBrowser = forceClose;
|
|
@@ -2193,11 +2381,23 @@ async function runSessionOwner(options) {
|
|
|
2193
2381
|
}
|
|
2194
2382
|
});
|
|
2195
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
|
+
}
|
|
2196
2395
|
if (closing) {
|
|
2197
2396
|
await closeCrawler();
|
|
2198
2397
|
return;
|
|
2199
2398
|
}
|
|
2200
2399
|
await crawler.openHomePage();
|
|
2400
|
+
await withDeadline(checkpoint(), remainingMs(deadlineAt), expireSession);
|
|
2201
2401
|
} catch (error) {
|
|
2202
2402
|
startupError = error;
|
|
2203
2403
|
}
|
|
@@ -2206,17 +2406,16 @@ async function runSessionOwner(options) {
|
|
|
2206
2406
|
if (!request || request.sessionId !== options.sessionId || !isAction(request.action) || !Number.isFinite(request.timeoutMs) || request.timeoutMs < 1 || !Number.isFinite(request.deadlineAt)) {
|
|
2207
2407
|
return failureResult("inspect", "invalid_request", "The session request was invalid.", options.sessionId);
|
|
2208
2408
|
}
|
|
2409
|
+
if (closing) return failureResult(request.action, "session_unavailable", "The browser owner is closing. Start to restore the saved session.", options.sessionId);
|
|
2209
2410
|
const deadlineAt = request.deadlineAt;
|
|
2210
2411
|
if (remainingMs(deadlineAt) === 0) {
|
|
2211
2412
|
return failureResult(request.action, "deadline_exceeded", "The browser action exceeded its deadline.", options.sessionId);
|
|
2212
2413
|
}
|
|
2213
2414
|
if (request.action === "start" && startComplete) {
|
|
2214
|
-
return
|
|
2215
|
-
}
|
|
2216
|
-
if (!startComplete && request.action !== "start") {
|
|
2217
|
-
return failureResult(request.action, "session_starting", "The browser session is not ready yet.", options.sessionId);
|
|
2415
|
+
return { ...await handleRequest({ ...request, action: "inspect" }), action: "start" };
|
|
2218
2416
|
}
|
|
2219
2417
|
if (request.action === "stop") {
|
|
2418
|
+
await withDeadline(checkpoint(), Math.min(500, Math.max(1, remainingMs(deadlineAt)))).catch(() => void 0);
|
|
2220
2419
|
closing = true;
|
|
2221
2420
|
abortOnShutdown = true;
|
|
2222
2421
|
try {
|
|
@@ -2227,6 +2426,7 @@ async function runSessionOwner(options) {
|
|
|
2227
2426
|
sessionId: options.sessionId,
|
|
2228
2427
|
action: "stop",
|
|
2229
2428
|
outcome: "success",
|
|
2429
|
+
authentication: { status: "unknown" },
|
|
2230
2430
|
url: null,
|
|
2231
2431
|
visibleState: null,
|
|
2232
2432
|
device: null,
|
|
@@ -2236,7 +2436,12 @@ async function runSessionOwner(options) {
|
|
|
2236
2436
|
return failureResult("stop", failureCode(error2), messageFor(error2), options.sessionId);
|
|
2237
2437
|
}
|
|
2238
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;
|
|
2239
2443
|
if (!startComplete) {
|
|
2444
|
+
clearTimeout(startupLease);
|
|
2240
2445
|
initializationPromise ??= initialize(deadlineAt);
|
|
2241
2446
|
try {
|
|
2242
2447
|
await withDeadline(initializationPromise, remainingMs(deadlineAt), expireSession);
|
|
@@ -2265,6 +2470,7 @@ async function runSessionOwner(options) {
|
|
|
2265
2470
|
sessionId: options.sessionId,
|
|
2266
2471
|
action: "start",
|
|
2267
2472
|
outcome: actionError2 ? "failure" : "success",
|
|
2473
|
+
authentication: inspected2?.authentication ?? { status: "unknown" },
|
|
2268
2474
|
url: inspected2?.url ?? (startupError ? safeUrl(options.origin) : page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
2269
2475
|
visibleState: inspected2?.visibleState ?? null,
|
|
2270
2476
|
device: inspected2?.device ?? null,
|
|
@@ -2291,12 +2497,14 @@ async function runSessionOwner(options) {
|
|
|
2291
2497
|
}
|
|
2292
2498
|
let guidedError;
|
|
2293
2499
|
let submitted = false;
|
|
2294
|
-
const actionDeadlineAt = deadlineAt - Math.min(1500, Math.floor(remainingMs(deadlineAt) /
|
|
2295
|
-
|
|
2296
|
-
const
|
|
2297
|
-
|
|
2298
|
-
|
|
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);
|
|
2299
2506
|
};
|
|
2507
|
+
const actionTimeout = () => Math.max(1, remainingMs(actionDeadlineAt));
|
|
2300
2508
|
page.setDefaultTimeout?.(actionTimeout());
|
|
2301
2509
|
page.setDefaultNavigationTimeout?.(actionTimeout());
|
|
2302
2510
|
let loginAttempt;
|
|
@@ -2305,16 +2513,16 @@ async function runSessionOwner(options) {
|
|
|
2305
2513
|
};
|
|
2306
2514
|
const zerodha = new PlaywrightZerodhaLoginPage(page, actionTimeout());
|
|
2307
2515
|
try {
|
|
2308
|
-
if (request.action === "operator-login") await
|
|
2309
|
-
if (request.action === "operator-logout") await
|
|
2310
|
-
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());
|
|
2311
2519
|
if (request.action === "zerodha-otp") {
|
|
2312
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.");
|
|
2313
2521
|
crawler.controls().registerSecrets(request.otp);
|
|
2314
|
-
const result = await
|
|
2522
|
+
const result = await runAction(zerodha.submitActiveOtp(request.attemptId, request.otp, onSubmit), actionTimeout());
|
|
2315
2523
|
if (result.status !== "SUCCESS") throw new OperatorActionError("otp_rejected", result.message);
|
|
2316
2524
|
}
|
|
2317
|
-
if (request.action === "zerodha-logout") await
|
|
2525
|
+
if (request.action === "zerodha-logout") await runAction(new PlaywrightOperatorPage(page, actionTimeout()).clickLogoutZerodha(onSubmit), actionTimeout());
|
|
2318
2526
|
} catch (error2) {
|
|
2319
2527
|
guidedError = error2;
|
|
2320
2528
|
}
|
|
@@ -2324,9 +2532,9 @@ async function runSessionOwner(options) {
|
|
|
2324
2532
|
try {
|
|
2325
2533
|
if (isControlAction(request)) {
|
|
2326
2534
|
const budget = Math.max(1, remainingMs(deadlineAt) - Math.min(2e3, request.timeoutMs / 4));
|
|
2327
|
-
controlResult = await
|
|
2535
|
+
controlResult = await runAction(crawler.controls().act(request, budget, () => {
|
|
2328
2536
|
submitted = true;
|
|
2329
|
-
}), budget
|
|
2537
|
+
}), budget);
|
|
2330
2538
|
}
|
|
2331
2539
|
} catch (error2) {
|
|
2332
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;
|
|
@@ -2334,6 +2542,25 @@ async function runSessionOwner(options) {
|
|
|
2334
2542
|
await withDeadline(crawler.controls().stopLoading(), Math.min(500, remainingMs(deadlineAt))).catch(() => void 0);
|
|
2335
2543
|
}
|
|
2336
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
|
+
}
|
|
2337
2564
|
try {
|
|
2338
2565
|
inspected = await withDeadline(inspectPage(page, options.mode, crawler), remainingMs(deadlineAt), expireSession);
|
|
2339
2566
|
} catch (error2) {
|
|
@@ -2359,13 +2586,13 @@ async function runSessionOwner(options) {
|
|
|
2359
2586
|
}
|
|
2360
2587
|
}
|
|
2361
2588
|
}
|
|
2362
|
-
if (actionTimedOut) closing = true;
|
|
2363
2589
|
const error = guidedError ?? actionError ?? closeError;
|
|
2364
2590
|
return {
|
|
2365
2591
|
version: RESULT_VERSION,
|
|
2366
2592
|
sessionId: options.sessionId,
|
|
2367
2593
|
action: request.action,
|
|
2368
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" },
|
|
2369
2596
|
...!actionError ? controlResult : {},
|
|
2370
2597
|
...loginAttempt ? { loginAttempt: { attemptId: loginAttempt.attemptId, status: loginAttempt.status } } : {},
|
|
2371
2598
|
url: inspected?.url ?? (page ? safeUrl(crawler?.controls().redact(page.url()) ?? page.url()) : null),
|
|
@@ -2378,6 +2605,7 @@ async function runSessionOwner(options) {
|
|
|
2378
2605
|
function shutdown() {
|
|
2379
2606
|
if (!closing) return Promise.resolve();
|
|
2380
2607
|
if (shutdownPromise) return shutdownPromise;
|
|
2608
|
+
clearTimeout(startupLease);
|
|
2381
2609
|
shutdownPromise = (async () => {
|
|
2382
2610
|
for (const socket of sockets) socket.destroy();
|
|
2383
2611
|
await unlink2(socketPath).catch(() => void 0);
|
|
@@ -2404,6 +2632,7 @@ async function runSessionOwner(options) {
|
|
|
2404
2632
|
}
|
|
2405
2633
|
if (crawler) {
|
|
2406
2634
|
try {
|
|
2635
|
+
await withDeadline(checkpoint(), OWNER_CLEANUP_GRACE_MS);
|
|
2407
2636
|
await closeCrawler();
|
|
2408
2637
|
} catch {
|
|
2409
2638
|
await crawler.forceClose?.().catch(() => void 0);
|
|
@@ -2411,6 +2640,8 @@ async function runSessionOwner(options) {
|
|
|
2411
2640
|
} else if (forceClosePendingBrowser) {
|
|
2412
2641
|
await forceCloseBrowser().catch(() => void 0);
|
|
2413
2642
|
}
|
|
2643
|
+
await withDeadline(checkpointQueue, OWNER_CLEANUP_GRACE_MS).catch(() => void 0);
|
|
2644
|
+
saved?.close();
|
|
2414
2645
|
process.removeListener("SIGTERM", onSignal);
|
|
2415
2646
|
process.removeListener("SIGINT", onSignal);
|
|
2416
2647
|
})();
|
|
@@ -2423,6 +2654,11 @@ async function runSessionOwner(options) {
|
|
|
2423
2654
|
void chmod2(socketPath, 384).then(resolve, reject);
|
|
2424
2655
|
});
|
|
2425
2656
|
});
|
|
2657
|
+
startupLease = setTimeout(() => {
|
|
2658
|
+
if (initializationPromise) return;
|
|
2659
|
+
expireSession();
|
|
2660
|
+
void shutdown();
|
|
2661
|
+
}, options.timeoutMs);
|
|
2426
2662
|
const onSignal = () => {
|
|
2427
2663
|
closing = true;
|
|
2428
2664
|
void shutdown();
|
|
@@ -2434,7 +2670,7 @@ async function wait(milliseconds) {
|
|
|
2434
2670
|
return new Promise((resolve) => setTimeout(resolve, milliseconds));
|
|
2435
2671
|
}
|
|
2436
2672
|
function assertOwnedSocket(socketPath) {
|
|
2437
|
-
return
|
|
2673
|
+
return lstat3(socketPath).then((entry) => {
|
|
2438
2674
|
const uid = process.getuid?.();
|
|
2439
2675
|
if (!entry.isSocket() || uid !== void 0 && entry.uid !== uid) {
|
|
2440
2676
|
throw new Error("The lmt-web session socket is not owned by the invoking user.");
|
|
@@ -2515,7 +2751,7 @@ var HELP = `lmt-web \u2014 browser interaction with Live Market Terminal Web
|
|
|
2515
2751
|
|
|
2516
2752
|
Usage:
|
|
2517
2753
|
lmt-web install-browser [--with-deps] [--dry-run]
|
|
2518
|
-
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]
|
|
2519
2755
|
lmt-web inspect --session ID [--full-page] [--timeout 30s]
|
|
2520
2756
|
lmt-web click --session ID --ref REF
|
|
2521
2757
|
lmt-web fill --session ID --ref REF --value TEXT [--secret]
|
|
@@ -2535,7 +2771,8 @@ Usage:
|
|
|
2535
2771
|
lmt-web close --session ID [--full-page] [--timeout 30s]
|
|
2536
2772
|
lmt-web stop --session ID [--timeout 30s]
|
|
2537
2773
|
|
|
2538
|
-
Start
|
|
2774
|
+
Start restores the saved session or reuses its live browser. --fresh is temporary.
|
|
2775
|
+
Mobile emulation
|
|
2539
2776
|
and headless mode are the defaults. Inspect and close require the returned session ID.
|
|
2540
2777
|
Stop terminates the session's browser and owner without capturing a final screenshot.
|
|
2541
2778
|
Each command writes one versioned JSON result to stdout; diagnostics go to stderr.
|
|
@@ -2601,6 +2838,7 @@ async function parseCommand(argv) {
|
|
|
2601
2838
|
environment: values.get("--env") ?? "",
|
|
2602
2839
|
mode,
|
|
2603
2840
|
headless: !flags.has("--headed"),
|
|
2841
|
+
fresh: flags.has("--fresh"),
|
|
2604
2842
|
timeoutMs
|
|
2605
2843
|
};
|
|
2606
2844
|
}
|
|
@@ -2664,47 +2902,69 @@ async function startSession(command) {
|
|
|
2664
2902
|
throw new InvocationError(error instanceof Error ? error.message : "The selected target is invalid.", "invalid_target");
|
|
2665
2903
|
}
|
|
2666
2904
|
await ensureSessionDirectory();
|
|
2667
|
-
const
|
|
2668
|
-
const
|
|
2669
|
-
|
|
2670
|
-
|
|
2671
|
-
|
|
2672
|
-
|
|
2673
|
-
|
|
2674
|
-
|
|
2675
|
-
|
|
2676
|
-
|
|
2677
|
-
|
|
2678
|
-
|
|
2679
|
-
|
|
2680
|
-
|
|
2681
|
-
|
|
2682
|
-
|
|
2683
|
-
|
|
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));
|
|
2684
2952
|
}
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
2692
|
-
});
|
|
2693
|
-
child.unref();
|
|
2694
|
-
const result = await requestSession({ sessionId, action: "start", timeoutMs: command.timeoutMs }, {
|
|
2695
|
-
waitForSocket: true,
|
|
2696
|
-
ownerAlive: () => ownerAlive
|
|
2697
|
-
});
|
|
2698
|
-
if (result.outcome === "failure" && result.error?.code === "deadline_exceeded") {
|
|
2699
|
-
try {
|
|
2700
|
-
process.kill(child.pid, "SIGTERM");
|
|
2701
|
-
} 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
|
+
}
|
|
2702
2960
|
}
|
|
2961
|
+
throw error;
|
|
2962
|
+
} finally {
|
|
2963
|
+
store?.close();
|
|
2703
2964
|
}
|
|
2704
|
-
return result;
|
|
2705
2965
|
}
|
|
2706
2966
|
async function runOwner(argv) {
|
|
2707
|
-
const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment] = argv;
|
|
2967
|
+
const [sessionId, origin, modeValue, headlessValue, timeoutValue, environment, persistence] = argv;
|
|
2708
2968
|
if (!isSessionId(sessionId) || !origin || modeValue !== "mobile" && modeValue !== "desktop" || headlessValue !== "headless" && headlessValue !== "headed" || !timeoutValue || !/^\d+$/.test(timeoutValue)) {
|
|
2709
2969
|
process.exitCode = 2;
|
|
2710
2970
|
return;
|
|
@@ -2715,7 +2975,8 @@ async function runOwner(argv) {
|
|
|
2715
2975
|
environment: environment === "dev" ? "dev" : "local",
|
|
2716
2976
|
mode: modeValue,
|
|
2717
2977
|
headless: headlessValue === "headless",
|
|
2718
|
-
timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS)
|
|
2978
|
+
timeoutMs: Math.min(Number(timeoutValue), MAX_TIMEOUT_MS),
|
|
2979
|
+
persist: persistence === "saved"
|
|
2719
2980
|
});
|
|
2720
2981
|
}
|
|
2721
2982
|
async function installBrowser(options) {
|
|
@@ -2728,7 +2989,7 @@ async function installBrowser(options) {
|
|
|
2728
2989
|
}
|
|
2729
2990
|
try {
|
|
2730
2991
|
const require2 = createRequire(import.meta.url);
|
|
2731
|
-
const browserCli =
|
|
2992
|
+
const browserCli = path6.join(path6.dirname(require2.resolve("playwright/package.json")), "cli.js");
|
|
2732
2993
|
const code = await new Promise((resolve, reject) => {
|
|
2733
2994
|
const child = spawn2(process.execPath, [browserCli, "install", ...options, "chromium"], {
|
|
2734
2995
|
stdio: ["ignore", "pipe", "pipe"]
|
|
@@ -2824,7 +3085,7 @@ async function runCli(argv) {
|
|
|
2824
3085
|
}
|
|
2825
3086
|
} catch (error) {
|
|
2826
3087
|
const action = command?.action ?? (error instanceof InvocationError ? error.action : void 0) ?? (argv[0] === "inspect" || argv[0] === "close" ? argv[0] : "start");
|
|
2827
|
-
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";
|
|
2828
3089
|
const message = error instanceof Error ? error.message : "The lmt-web command failed.";
|
|
2829
3090
|
const result = failureResult(action, code, message);
|
|
2830
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",
|