libretto 0.6.24 → 0.6.25

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.
Files changed (63) hide show
  1. package/README.md +9 -1
  2. package/README.template.md +9 -1
  3. package/dist/cli/commands/browser.js +17 -10
  4. package/dist/cli/commands/cloud-credentials.js +70 -0
  5. package/dist/cli/commands/deploy.js +24 -2
  6. package/dist/cli/commands/execution.js +9 -30
  7. package/dist/cli/commands/import-chrome-profiles.js +46 -0
  8. package/dist/cli/commands/profiles.js +71 -0
  9. package/dist/cli/commands/shared.js +1 -3
  10. package/dist/cli/core/browser.js +89 -75
  11. package/dist/cli/core/daemon/daemon.js +47 -35
  12. package/dist/cli/core/daemon/ipc.js +3 -0
  13. package/dist/cli/core/deploy-artifact.js +85 -22
  14. package/dist/cli/core/profiles.js +47 -0
  15. package/dist/cli/core/prompt.js +9 -0
  16. package/dist/cli/core/providers/libretto-cloud.js +6 -2
  17. package/dist/cli/core/session-logs.js +325 -0
  18. package/dist/cli/core/telemetry.js +83 -313
  19. package/dist/cli/core/workflow-runner/runner.js +65 -0
  20. package/dist/cli/router.js +9 -1
  21. package/dist/index.d.ts +2 -0
  22. package/dist/index.js +12 -0
  23. package/dist/shared/workflow/auth-profile-name.d.ts +3 -0
  24. package/dist/shared/workflow/auth-profile-name.js +29 -0
  25. package/dist/shared/workflow/auth-profile-state.d.ts +20 -0
  26. package/dist/shared/workflow/auth-profile-state.js +105 -0
  27. package/dist/shared/workflow/authenticate.d.ts +17 -0
  28. package/dist/shared/workflow/authenticate.js +37 -0
  29. package/dist/shared/workflow/credentials.d.ts +5 -0
  30. package/dist/shared/workflow/credentials.js +68 -0
  31. package/dist/shared/workflow/workflow.d.ts +16 -1
  32. package/dist/shared/workflow/workflow.js +56 -4
  33. package/package.json +1 -1
  34. package/skills/libretto/SKILL.md +3 -4
  35. package/skills/libretto/references/auth-profiles.md +61 -11
  36. package/skills/libretto/references/code-generation-rules.md +31 -1
  37. package/skills/libretto-readonly/SKILL.md +1 -1
  38. package/src/cli/commands/browser.ts +19 -11
  39. package/src/cli/commands/cloud-credentials.ts +82 -0
  40. package/src/cli/commands/deploy.ts +41 -2
  41. package/src/cli/commands/execution.ts +10 -31
  42. package/src/cli/commands/import-chrome-profiles.ts +46 -0
  43. package/src/cli/commands/profiles.ts +90 -0
  44. package/src/cli/commands/shared.ts +4 -8
  45. package/src/cli/core/browser.ts +102 -91
  46. package/src/cli/core/daemon/config.ts +4 -1
  47. package/src/cli/core/daemon/daemon.ts +52 -44
  48. package/src/cli/core/daemon/ipc.ts +15 -0
  49. package/src/cli/core/deploy-artifact.ts +131 -32
  50. package/src/cli/core/profiles.ts +53 -0
  51. package/src/cli/core/prompt.ts +15 -0
  52. package/src/cli/core/providers/libretto-cloud.ts +6 -2
  53. package/src/cli/core/providers/types.ts +4 -1
  54. package/src/cli/core/session-logs.ts +445 -0
  55. package/src/cli/core/telemetry.ts +105 -422
  56. package/src/cli/core/workflow-runner/runner.ts +86 -1
  57. package/src/cli/router.ts +8 -0
  58. package/src/index.ts +10 -0
  59. package/src/shared/workflow/auth-profile-name.ts +27 -0
  60. package/src/shared/workflow/auth-profile-state.ts +144 -0
  61. package/src/shared/workflow/authenticate.ts +63 -0
  62. package/src/shared/workflow/credentials.ts +91 -0
  63. package/src/shared/workflow/workflow.ts +89 -4
@@ -1,445 +1,128 @@
1
- import {
2
- appendFileSync,
3
- existsSync,
4
- readFileSync,
5
- } from "node:fs";
6
- import type { Page } from "playwright";
7
- import {
8
- getSessionActionsLogPath,
9
- getSessionNetworkLogPath,
10
- } from "./context.js";
11
- import { assertSessionStateExistsOrThrow } from "./session.js";
12
-
13
- export type NetworkLogEntry = {
14
- id?: number;
15
- ts: string;
16
- pageId?: string;
17
- method: string;
18
- url: string;
19
- resourceType?: string;
20
- status: number | null;
21
- statusText?: string | null;
22
- contentType: string | null;
23
- requestHeaders?: Record<string, string> | null;
24
- responseHeaders?: Record<string, string> | null;
25
- requestBodyPreview?: string | null;
26
- requestBodyPath?: string | null;
27
- requestBodyBytes?: number | null;
28
- requestBodyTruncated?: boolean;
29
- requestBodyOmittedReason?: string | null;
30
- responseBodyPreview?: string | null;
31
- responseBodyPath?: string | null;
32
- responseBodyBytes?: number | null;
33
- responseBodyTruncated?: boolean;
34
- responseBodyOmittedReason?: string | null;
35
- errorText?: string | null;
36
- postData?: string;
37
- responseBody?: string | null;
38
- size?: number | null;
39
- durationMs?: number | null;
1
+ import { randomUUID } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import type { SimpleCLICommandMeta, SimpleCLIMiddleware } from "affordance";
6
+ import { resolveHostedApiUrl } from "./auth-fetch.js";
7
+
8
+ const TELEMETRY_FILE_NAME = "telemetry.json";
9
+ const TELEMETRY_ENDPOINT_PATH = "/v1/telemetry/recordCliEvent";
10
+ const TELEMETRY_TIMEOUT_MS = 250;
11
+
12
+ type StoredTelemetryState = {
13
+ installId?: string;
14
+ enabled?: boolean;
40
15
  };
41
16
 
42
- export function readNetworkLog(
43
- session: string,
44
- opts: {
45
- last?: number;
46
- filter?: string;
47
- method?: string;
48
- pageId?: string;
49
- } = {},
50
- ): NetworkLogEntry[] {
51
- assertSessionStateExistsOrThrow(session);
52
- const logPath = getSessionNetworkLogPath(session);
53
- if (!existsSync(logPath)) return [];
54
-
55
- const lines = readFileSync(logPath, "utf-8")
56
- .trim()
57
- .split("\n")
58
- .filter(Boolean);
59
- let entries: NetworkLogEntry[] = lines.map(
60
- (line) => JSON.parse(line) as NetworkLogEntry,
61
- );
62
-
63
- if (opts.method) {
64
- const m = opts.method.toUpperCase();
65
- entries = entries.filter((e) => e.method === m);
66
- }
67
- if (opts.filter) {
68
- const re = new RegExp(opts.filter, "i");
69
- entries = entries.filter((e) => re.test(e.url));
70
- }
71
- if (opts.pageId) {
72
- entries = entries.filter((e) => e.pageId === opts.pageId);
73
- }
74
-
75
- const last = opts.last ?? 20;
76
- if (entries.length > last) {
77
- entries = entries.slice(-last);
78
- }
79
-
80
- return entries;
81
- }
82
-
83
- export type ActionLogEntry = {
84
- ts: string;
85
- pageId?: string;
86
- action: string;
87
- source: "user" | "agent";
88
- selector?: string;
89
- bestSemanticSelector?: string;
90
- targetSelector?: string;
91
- ancestorSelectors?: string[];
92
- nearbyText?: string;
93
- composedPath?: string[];
94
- coordinates?: {
95
- x: number;
96
- y: number;
97
- };
98
- value?: string;
99
- url?: string;
100
- duration?: number;
101
- success: boolean;
102
- error?: string;
17
+ type CliTelemetryPayload = {
18
+ installId: string;
19
+ timestamp: string;
20
+ event: string;
21
+ error: boolean;
103
22
  };
104
23
 
105
- export function parentLogAction(
106
- session: string,
107
- entry: Record<string, unknown>,
108
- ): void {
109
- try {
110
- const record = { ts: new Date().toISOString(), ...entry };
111
- appendFileSync(
112
- getSessionActionsLogPath(session),
113
- JSON.stringify(record) + "\n",
114
- );
115
- } catch {}
24
+ function telemetryDir(): string {
25
+ return join(homedir(), ".libretto");
116
26
  }
117
27
 
118
- export function readActionLog(
119
- session: string,
120
- opts: {
121
- last?: number;
122
- filter?: string;
123
- action?: string;
124
- source?: string;
125
- pageId?: string;
126
- } = {},
127
- ): ActionLogEntry[] {
128
- assertSessionStateExistsOrThrow(session);
129
- const logPath = getSessionActionsLogPath(session);
130
- if (!existsSync(logPath)) return [];
131
-
132
- const lines = readFileSync(logPath, "utf-8")
133
- .trim()
134
- .split("\n")
135
- .filter(Boolean);
136
- let entries: ActionLogEntry[] = lines.map(
137
- (line) => JSON.parse(line) as ActionLogEntry,
138
- );
139
-
140
- if (opts.action) {
141
- const a = opts.action.toLowerCase();
142
- entries = entries.filter((e) => e.action === a);
143
- }
144
- if (opts.source) {
145
- const s = opts.source.toLowerCase();
146
- entries = entries.filter((e) => e.source === s);
147
- }
148
- if (opts.filter) {
149
- const re = new RegExp(opts.filter, "i");
150
- entries = entries.filter(
151
- (e) =>
152
- re.test(e.action) ||
153
- re.test(e.selector || "") ||
154
- re.test(e.bestSemanticSelector || "") ||
155
- re.test(e.targetSelector || "") ||
156
- re.test((e.ancestorSelectors || []).join(" ")) ||
157
- re.test(e.nearbyText || "") ||
158
- re.test((e.composedPath || []).join(" ")) ||
159
- re.test(e.value || "") ||
160
- re.test(e.url || ""),
161
- );
162
- }
163
- if (opts.pageId) {
164
- entries = entries.filter((e) => e.pageId === opts.pageId);
165
- }
166
-
167
- const last = opts.last ?? 20;
168
- if (entries.length > last) {
169
- entries = entries.slice(-last);
170
- }
171
-
172
- return entries;
28
+ function telemetryPath(): string {
29
+ return join(telemetryDir(), TELEMETRY_FILE_NAME);
173
30
  }
174
31
 
175
- const LOCATOR_ACTION_METHODS = [
176
- "click",
177
- "dblclick",
178
- "fill",
179
- "type",
180
- "press",
181
- "check",
182
- "uncheck",
183
- "selectOption",
184
- "hover",
185
- "focus",
186
- "scrollIntoViewIfNeeded",
187
- "waitFor",
188
- "innerHTML",
189
- "innerText",
190
- "textContent",
191
- "inputValue",
192
- "isChecked",
193
- "isDisabled",
194
- "isEditable",
195
- "isEnabled",
196
- "isHidden",
197
- "isVisible",
198
- "count",
199
- "boundingBox",
200
- "screenshot",
201
- "evaluate",
202
- "evaluateAll",
203
- "evaluateHandle",
204
- "getAttribute",
205
- "dispatchEvent",
206
- "setInputFiles",
207
- "selectText",
208
- "dragTo",
209
- "highlight",
210
- "tap",
211
- ] as const;
212
-
213
- const LOCATOR_RETURNING_METHODS = [
214
- "first",
215
- "last",
216
- "locator",
217
- "getByRole",
218
- "getByText",
219
- "getByLabel",
220
- "getByPlaceholder",
221
- "getByAltText",
222
- "getByTitle",
223
- "getByTestId",
224
- "filter",
225
- "and",
226
- "or",
227
- ] as const;
228
-
229
- function formatHint(method: string, args: any[]): string {
230
- const formatted = args.map((a: any) => JSON.stringify(a)).join(", ");
231
- return `${method}(${formatted})`;
32
+ function isTelemetryDisabled(): boolean {
33
+ return (
34
+ process.env.LIBRETTO_TELEMETRY_DISABLED === "1" ||
35
+ process.env.DO_NOT_TRACK === "1" ||
36
+ process.env.CI === "1"
37
+ );
232
38
  }
233
39
 
234
- function wrapLocator(
235
- locator: any,
236
- hint: string,
237
- session: string,
238
- page: Page,
239
- pageId?: string,
240
- onActivity?: () => void,
241
- ): any {
242
- if (locator.__librettoActionLogged) return locator;
243
- locator.__librettoActionLogged = true;
244
-
245
- for (const actMethod of LOCATOR_ACTION_METHODS) {
246
- if (typeof locator[actMethod] !== "function") continue;
247
- const origAct = locator[actMethod].bind(locator);
248
- locator[actMethod] = async (...actArgs: any[]) => {
249
- const start = Date.now();
250
- try {
251
- await page.evaluate(() => {
252
- (window as any).__btApiActionInProgress = true;
253
- });
254
- } catch {}
255
- try {
256
- const result = await origAct(...actArgs);
257
- parentLogAction(session, {
258
- pageId,
259
- action: actMethod,
260
- source: "agent",
261
- selector: hint,
262
- value:
263
- actArgs[0] !== undefined
264
- ? String(actArgs[0]).slice(0, 100)
265
- : undefined,
266
- duration: Date.now() - start,
267
- success: true,
268
- });
269
- onActivity?.();
270
- return result;
271
- } catch (err: any) {
272
- parentLogAction(session, {
273
- pageId,
274
- action: actMethod,
275
- source: "agent",
276
- selector: hint,
277
- duration: Date.now() - start,
278
- success: false,
279
- error: err.message,
280
- });
281
- onActivity?.();
282
- throw err;
283
- } finally {
284
- try {
285
- await page.evaluate(() => {
286
- (window as any).__btApiActionInProgress = false;
287
- });
288
- } catch {}
289
- }
290
- };
40
+ async function readTelemetryState(): Promise<StoredTelemetryState | null> {
41
+ try {
42
+ const raw = await fs.readFile(telemetryPath(), "utf8");
43
+ return JSON.parse(raw) as Partial<StoredTelemetryState>;
44
+ } catch (error) {
45
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
46
+ return null;
291
47
  }
48
+ }
292
49
 
293
- for (const method of LOCATOR_RETURNING_METHODS) {
294
- if (typeof locator[method] !== "function") continue;
295
- const origMethod = locator[method].bind(locator);
296
- locator[method] = (...args: any[]) => {
297
- const child = origMethod(...args);
298
- const childHint =
299
- args.length > 0
300
- ? `${hint}.${formatHint(method, args)}`
301
- : `${hint}.${method}()`;
302
- return wrapLocator(child, childHint, session, page, pageId, onActivity);
303
- };
304
- }
50
+ async function readOrCreateInstallId(): Promise<string | null> {
51
+ const state = await readTelemetryState();
52
+ if (state?.enabled === false) return null;
305
53
 
306
- if (typeof locator.nth === "function") {
307
- const origNth = locator.nth.bind(locator);
308
- locator.nth = (index: number) => {
309
- const child = origNth(index);
310
- const childHint = `${hint}.nth(${index})`;
311
- return wrapLocator(child, childHint, session, page, pageId, onActivity);
312
- };
54
+ if (typeof state?.installId === "string" && state.installId.length > 0) {
55
+ return state.installId;
313
56
  }
314
57
 
315
- if (typeof locator.all === "function") {
316
- const origAll = locator.all.bind(locator);
317
- locator.all = async () => {
318
- const items: any[] = await origAll();
319
- return items.map((item: any, i: number) => {
320
- const childHint = `${hint}.all()[${i}]`;
321
- return wrapLocator(item, childHint, session, page, pageId, onActivity);
322
- });
323
- };
324
- }
58
+ const installId = randomUUID();
59
+ writeTelemetryNotice();
60
+ await writeTelemetryState({ installId, enabled: true });
61
+ return installId;
62
+ }
325
63
 
326
- return locator;
64
+ function writeTelemetryNotice(): void {
65
+ if (!process.stderr.isTTY) return;
66
+ process.stderr.write(
67
+ [
68
+ "Libretto collects anonymous CLI telemetry: install id, timestamp, command event, and error status only.",
69
+ "Set LIBRETTO_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1 to disable it, or set enabled:false in ~/.libretto/telemetry.json.",
70
+ ].join(" ") + "\n",
71
+ );
327
72
  }
328
73
 
329
- export function wrapPageForActionLogging(
330
- page: Page,
331
- session: string,
332
- pageId?: string,
333
- onActivity?: () => void,
334
- ): void {
335
- const PAGE_ACTIONS = [
336
- "click",
337
- "dblclick",
338
- "fill",
339
- "type",
340
- "press",
341
- "check",
342
- "uncheck",
343
- "selectOption",
344
- "hover",
345
- "focus",
346
- ] as const;
347
- const NAV_ACTIONS = ["goto", "reload", "goBack", "goForward"] as const;
74
+ async function writeTelemetryState(state: StoredTelemetryState): Promise<void> {
75
+ await fs.mkdir(telemetryDir(), { recursive: true, mode: 0o700 });
76
+ const target = telemetryPath();
77
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
78
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
79
+ await fs.rename(tmp, target);
80
+ }
348
81
 
349
- for (const method of PAGE_ACTIONS) {
350
- const orig = (page as any)[method].bind(page);
351
- (page as any)[method] = async (...args: any[]) => {
352
- const start = Date.now();
353
- try {
354
- await page.evaluate(() => {
355
- (window as any).__btApiActionInProgress = true;
356
- });
357
- } catch {}
358
- try {
359
- const result = await orig(...args);
360
- parentLogAction(session, {
361
- pageId,
362
- action: method,
363
- source: "agent",
364
- selector: typeof args[0] === "string" ? args[0] : undefined,
365
- value:
366
- args[1] !== undefined ? String(args[1]).slice(0, 100) : undefined,
367
- duration: Date.now() - start,
368
- success: true,
369
- });
370
- onActivity?.();
371
- return result;
372
- } catch (err: any) {
373
- parentLogAction(session, {
374
- pageId,
375
- action: method,
376
- source: "agent",
377
- selector: typeof args[0] === "string" ? args[0] : undefined,
378
- duration: Date.now() - start,
379
- success: false,
380
- error: err.message,
381
- });
382
- onActivity?.();
383
- throw err;
384
- } finally {
385
- try {
386
- await page.evaluate(() => {
387
- (window as any).__btApiActionInProgress = false;
388
- });
389
- } catch {}
390
- }
391
- };
392
- }
82
+ async function recordCliTelemetryEvent(
83
+ command: SimpleCLICommandMeta,
84
+ error: boolean,
85
+ ): Promise<void> {
86
+ if (isTelemetryDisabled()) return;
87
+ const installId = await readOrCreateInstallId();
88
+ if (!installId) return;
89
+
90
+ await sendWithTimeout({
91
+ installId,
92
+ timestamp: new Date().toISOString(),
93
+ event: `libretto ${command.path.join(" ")}`,
94
+ error,
95
+ });
96
+ }
393
97
 
394
- for (const method of NAV_ACTIONS) {
395
- const orig = (page as any)[method].bind(page);
396
- (page as any)[method] = async (...args: any[]) => {
397
- const start = Date.now();
398
- try {
399
- const result = await orig(...args);
400
- parentLogAction(session, {
401
- pageId,
402
- action: method,
403
- source: "agent",
404
- url: typeof args[0] === "string" ? args[0] : page.url(),
405
- duration: Date.now() - start,
406
- success: true,
407
- });
408
- onActivity?.();
409
- return result;
410
- } catch (err: any) {
411
- parentLogAction(session, {
412
- pageId,
413
- action: method,
414
- source: "agent",
415
- url: typeof args[0] === "string" ? args[0] : undefined,
416
- duration: Date.now() - start,
417
- success: false,
418
- error: err.message,
419
- });
420
- onActivity?.();
421
- throw err;
422
- }
423
- };
98
+ async function sendWithTimeout(payload: CliTelemetryPayload): Promise<void> {
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
101
+ try {
102
+ await fetch(`${resolveHostedApiUrl()}${TELEMETRY_ENDPOINT_PATH}`, {
103
+ method: "POST",
104
+ headers: {
105
+ "Content-Type": "application/json",
106
+ },
107
+ body: JSON.stringify({ json: payload }),
108
+ signal: controller.signal,
109
+ });
110
+ } finally {
111
+ clearTimeout(timeout);
424
112
  }
113
+ }
425
114
 
426
- const LOCATOR_FACTORIES = [
427
- "locator",
428
- "getByRole",
429
- "getByText",
430
- "getByLabel",
431
- "getByPlaceholder",
432
- "getByAltText",
433
- "getByTitle",
434
- "getByTestId",
435
- ] as const;
436
-
437
- for (const factory of LOCATOR_FACTORIES) {
438
- const orig = (page as any)[factory].bind(page);
439
- (page as any)[factory] = (...factoryArgs: any[]) => {
440
- const locator = orig(...factoryArgs);
441
- const hint = formatHint(factory, factoryArgs);
442
- return wrapLocator(locator, hint, session, page, pageId, onActivity);
443
- };
115
+ export const telemetryMiddleware: SimpleCLIMiddleware<
116
+ unknown,
117
+ {},
118
+ {}
119
+ > = async ({ command, next }) => {
120
+ try {
121
+ const result = await next();
122
+ await recordCliTelemetryEvent(command, false).catch(() => {});
123
+ return result;
124
+ } catch (error) {
125
+ await recordCliTelemetryEvent(command, true).catch(() => {});
126
+ throw error;
444
127
  }
445
- }
128
+ };
@@ -1,10 +1,15 @@
1
- import type { BrowserContext, Page } from "playwright";
1
+ import type { BrowserContext, Frame, Page } from "playwright";
2
2
  import type { LoggerApi } from "../../../shared/logger/index.js";
3
3
  import { installPauseHandler } from "../../../shared/debug/pause-handler.js";
4
+ import {
5
+ mergeAuthProfileStorageState,
6
+ normalizeAuthProfileSite,
7
+ } from "../../../shared/workflow/auth-profile-state.js";
4
8
  import type {
5
9
  ExportedLibrettoWorkflow,
6
10
  LibrettoWorkflowContext,
7
11
  } from "../../../shared/workflow/workflow.js";
12
+ import { readProfile, writeProfile } from "../profiles.js";
8
13
  import {
9
14
  getAbsoluteIntegrationPath,
10
15
  installHeadedWorkflowVisualization,
@@ -44,6 +49,7 @@ export type WorkflowControllerConfig = {
44
49
  page: Page;
45
50
  context: BrowserContext;
46
51
  logger: LoggerApi;
52
+ refreshLocalAuthProfiles?: boolean;
47
53
  onLog?: (event: WorkflowLogEvent) => void;
48
54
  onOutcome?: (outcome: WorkflowOutcome) => void;
49
55
  };
@@ -155,6 +161,7 @@ export class WorkflowController {
155
161
  session: this.config.session,
156
162
  page: this.config.page,
157
163
  };
164
+ const visitedSites = createVisitedSiteTracker(this.config.context);
158
165
 
159
166
  const uninstallPauseHandler = installPauseHandler((pauseArgs) =>
160
167
  this.pause({
@@ -164,6 +171,12 @@ export class WorkflowController {
164
171
  );
165
172
  try {
166
173
  await workflow.run(workflowContext, workflowConfig.params ?? {});
174
+ await refreshLocalAuthProfileIfEnabled({
175
+ context: this.config.context,
176
+ enabled: this.config.refreshLocalAuthProfiles === true,
177
+ sites: visitedSites.sites(),
178
+ workflow,
179
+ });
167
180
  } catch (error) {
168
181
  this.emitOutcome({
169
182
  state: "finished",
@@ -174,6 +187,7 @@ export class WorkflowController {
174
187
  return;
175
188
  } finally {
176
189
  uninstallPauseHandler();
190
+ visitedSites.dispose();
177
191
  }
178
192
 
179
193
  this.emitOutcome({
@@ -232,6 +246,77 @@ export class WorkflowController {
232
246
  }
233
247
  }
234
248
 
249
+ async function refreshLocalAuthProfileIfEnabled(
250
+ args: {
251
+ context: BrowserContext;
252
+ enabled: boolean;
253
+ sites: readonly string[];
254
+ workflow: ExportedLibrettoWorkflow;
255
+ },
256
+ ): Promise<void> {
257
+ const { context, enabled, sites, workflow } = args;
258
+ if (!workflow.authProfileName || workflow.authProfileRefresh !== true) {
259
+ return;
260
+ }
261
+ if (!enabled) return;
262
+ if (sites.length === 0) {
263
+ console.warn(
264
+ `Auth profile refresh skipped for "${workflow.authProfileName}": workflow did not visit any http(s) sites.`,
265
+ );
266
+ return;
267
+ }
268
+ const existing = readProfile(workflow.authProfileName);
269
+ const latest = await context.storageState({ indexedDB: true });
270
+ const state = mergeAuthProfileStorageState(existing, latest, sites);
271
+ await writeProfile(workflow.authProfileName, state);
272
+ console.warn(`Auth profile refreshed: ${workflow.authProfileName}`);
273
+ }
274
+
275
+ function createVisitedSiteTracker(context: BrowserContext): {
276
+ sites: () => string[];
277
+ dispose: () => void;
278
+ } {
279
+ const sites = new Set<string>();
280
+ const pageListeners = new Map<Page, (frame: Frame) => void>();
281
+
282
+ const recordUrl = (url: string): void => {
283
+ if (!url.startsWith("http://") && !url.startsWith("https://")) return;
284
+ const site = normalizeAuthProfileSite(url);
285
+ if (site) sites.add(site);
286
+ };
287
+
288
+ const trackPage = (page: Page): void => {
289
+ if (pageListeners.has(page)) return;
290
+ recordUrl(page.url());
291
+
292
+ const onFrameNavigated = (frame: Frame): void => {
293
+ if (frame === page.mainFrame()) {
294
+ recordUrl(frame.url());
295
+ }
296
+ };
297
+
298
+ pageListeners.set(page, onFrameNavigated);
299
+ page.on("framenavigated", onFrameNavigated);
300
+ };
301
+
302
+ for (const page of context.pages()) {
303
+ trackPage(page);
304
+ }
305
+
306
+ context.on("page", trackPage);
307
+
308
+ return {
309
+ sites: () => [...sites],
310
+ dispose: () => {
311
+ context.off("page", trackPage);
312
+ for (const [page, listener] of pageListeners) {
313
+ page.off("framenavigated", listener);
314
+ }
315
+ pageListeners.clear();
316
+ },
317
+ };
318
+ }
319
+
235
320
  function chunkToString(chunk: unknown): string {
236
321
  return Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
237
322
  }