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