libretto 0.6.24 → 0.6.26

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 +110 -311
  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 +142 -413
  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,174 @@
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 { existsSync, readFileSync } from "node:fs";
3
+ import { promises as fs } from "node:fs";
4
+ import { homedir } from "node:os";
5
+ import { basename, dirname, join } from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import type { SimpleCLICommandMeta, SimpleCLIMiddleware } from "affordance";
8
+ import { resolveHostedApiUrl } from "./auth-fetch.js";
9
+
10
+ const TELEMETRY_FILE_NAME = "telemetry.json";
11
+ const TELEMETRY_ENDPOINT_PATH = "/v1/telemetry/recordCliEvent";
12
+ const TELEMETRY_TIMEOUT_MS = 250;
13
+
14
+ type BuildChannel = "node_modules" | "source" | "unknown";
15
+
16
+ type StoredTelemetryState = {
17
+ installId?: string;
18
+ enabled?: boolean;
40
19
  };
41
20
 
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
- }
21
+ type CliTelemetryPayload = {
22
+ installId: string;
23
+ timestamp: string;
24
+ event: string;
25
+ error: boolean;
26
+ packageVersion: string;
27
+ buildChannel: BuildChannel;
28
+ };
74
29
 
75
- const last = opts.last ?? 20;
76
- if (entries.length > last) {
77
- entries = entries.slice(-last);
78
- }
30
+ type PackageJson = {
31
+ version?: unknown;
32
+ };
79
33
 
80
- return entries;
34
+ function packageRoot(): string {
35
+ return join(dirname(fileURLToPath(import.meta.url)), "../../..");
81
36
  }
82
37
 
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;
103
- };
104
-
105
- export function parentLogAction(
106
- session: string,
107
- entry: Record<string, unknown>,
108
- ): void {
38
+ function readPackageVersion(): string {
109
39
  try {
110
- const record = { ts: new Date().toISOString(), ...entry };
111
- appendFileSync(
112
- getSessionActionsLogPath(session),
113
- JSON.stringify(record) + "\n",
114
- );
115
- } catch {}
40
+ const parsed = JSON.parse(
41
+ readFileSync(join(packageRoot(), "package.json"), "utf8"),
42
+ ) as PackageJson;
43
+ return typeof parsed.version === "string" ? parsed.version : "unknown";
44
+ } catch {
45
+ return "unknown";
46
+ }
116
47
  }
117
48
 
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);
49
+ function resolveBuildChannel(): BuildChannel {
50
+ const root = packageRoot();
51
+ const workspaceRoot = join(root, "../..");
52
+ if (
53
+ basename(dirname(root)) === "packages" &&
54
+ existsSync(join(workspaceRoot, "pnpm-workspace.yaml"))
55
+ ) {
56
+ return "source";
165
57
  }
166
58
 
167
- const last = opts.last ?? 20;
168
- if (entries.length > last) {
169
- entries = entries.slice(-last);
170
- }
59
+ const pathSegments = root.split(/[\\/]+/);
60
+ if (pathSegments.includes("node_modules")) return "node_modules";
171
61
 
172
- return entries;
62
+ return "unknown";
173
63
  }
174
64
 
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;
65
+ const packageVersion = readPackageVersion();
66
+ const buildChannel = resolveBuildChannel();
212
67
 
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;
68
+ function telemetryDir(): string {
69
+ return join(homedir(), ".libretto");
70
+ }
228
71
 
229
- function formatHint(method: string, args: any[]): string {
230
- const formatted = args.map((a: any) => JSON.stringify(a)).join(", ");
231
- return `${method}(${formatted})`;
72
+ function telemetryPath(): string {
73
+ return join(telemetryDir(), TELEMETRY_FILE_NAME);
232
74
  }
233
75
 
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;
76
+ function isTelemetryDisabled(): boolean {
77
+ return (
78
+ process.env.LIBRETTO_TELEMETRY_DISABLED === "1" ||
79
+ process.env.DO_NOT_TRACK === "1" ||
80
+ process.env.CI === "1"
81
+ );
82
+ }
244
83
 
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
- };
84
+ async function readTelemetryState(): Promise<StoredTelemetryState | null> {
85
+ try {
86
+ const raw = await fs.readFile(telemetryPath(), "utf8");
87
+ return JSON.parse(raw) as Partial<StoredTelemetryState>;
88
+ } catch (error) {
89
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
90
+ return null;
291
91
  }
92
+ }
292
93
 
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
- }
94
+ async function readOrCreateInstallId(): Promise<string | null> {
95
+ const state = await readTelemetryState();
96
+ if (state?.enabled === false) return null;
305
97
 
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
- };
98
+ if (typeof state?.installId === "string" && state.installId.length > 0) {
99
+ return state.installId;
313
100
  }
314
101
 
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
- }
102
+ const installId = randomUUID();
103
+ writeTelemetryNotice();
104
+ await writeTelemetryState({ installId, enabled: true });
105
+ return installId;
106
+ }
325
107
 
326
- return locator;
108
+ function writeTelemetryNotice(): void {
109
+ if (!process.stderr.isTTY) return;
110
+ process.stderr.write(
111
+ [
112
+ "Libretto collects anonymous CLI telemetry: install id, timestamp, command event, error status, package version, and build channel only.",
113
+ "Set LIBRETTO_TELEMETRY_DISABLED=1 or DO_NOT_TRACK=1 to disable it, or set enabled:false in ~/.libretto/telemetry.json.",
114
+ ].join(" ") + "\n",
115
+ );
327
116
  }
328
117
 
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;
118
+ async function writeTelemetryState(state: StoredTelemetryState): Promise<void> {
119
+ await fs.mkdir(telemetryDir(), { recursive: true, mode: 0o700 });
120
+ const target = telemetryPath();
121
+ const tmp = `${target}.${process.pid}.${randomUUID()}.tmp`;
122
+ await fs.writeFile(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
123
+ await fs.rename(tmp, target);
124
+ }
348
125
 
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
- }
126
+ async function recordCliTelemetryEvent(
127
+ command: SimpleCLICommandMeta,
128
+ error: boolean,
129
+ ): Promise<void> {
130
+ if (isTelemetryDisabled()) return;
131
+ const installId = await readOrCreateInstallId();
132
+ if (!installId) return;
133
+
134
+ await sendWithTimeout({
135
+ installId,
136
+ timestamp: new Date().toISOString(),
137
+ event: `libretto ${command.path.join(" ")}`,
138
+ error,
139
+ packageVersion,
140
+ buildChannel,
141
+ });
142
+ }
393
143
 
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
- };
144
+ async function sendWithTimeout(payload: CliTelemetryPayload): Promise<void> {
145
+ const controller = new AbortController();
146
+ const timeout = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
147
+ try {
148
+ await fetch(`${resolveHostedApiUrl()}${TELEMETRY_ENDPOINT_PATH}`, {
149
+ method: "POST",
150
+ headers: {
151
+ "Content-Type": "application/json",
152
+ },
153
+ body: JSON.stringify({ json: payload }),
154
+ signal: controller.signal,
155
+ });
156
+ } finally {
157
+ clearTimeout(timeout);
424
158
  }
159
+ }
425
160
 
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
- };
161
+ export const telemetryMiddleware: SimpleCLIMiddleware<
162
+ unknown,
163
+ {},
164
+ {}
165
+ > = async ({ command, next }) => {
166
+ try {
167
+ const result = await next();
168
+ await recordCliTelemetryEvent(command, false).catch(() => {});
169
+ return result;
170
+ } catch (error) {
171
+ await recordCliTelemetryEvent(command, true).catch(() => {});
172
+ throw error;
444
173
  }
445
- }
174
+ };