libretto 0.5.4 → 0.5.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.
@@ -47,19 +47,44 @@ async function pickFreePort() {
47
47
  server.on("error", reject);
48
48
  });
49
49
  }
50
+ function tryParseAbsoluteUrl(url) {
51
+ try {
52
+ return new URL(url);
53
+ } catch {
54
+ return null;
55
+ }
56
+ }
57
+ function isLikelyHostWithPort(parsedUrl, rawUrl) {
58
+ const remainder = rawUrl.slice(parsedUrl.protocol.length);
59
+ if (remainder.length === 0) return false;
60
+ let index = 0;
61
+ while (index < remainder.length) {
62
+ const charCode = remainder.charCodeAt(index);
63
+ if (charCode < 48 || charCode > 57) break;
64
+ index += 1;
65
+ }
66
+ if (index === 0) return false;
67
+ if (index === remainder.length) return true;
68
+ const nextChar = remainder[index];
69
+ return nextChar === "/" || nextChar === "?" || nextChar === "#";
70
+ }
50
71
  function normalizeUrl(url) {
51
- if (!/^https?:\/\//i.test(url)) {
52
- return `https://${url}`;
72
+ const parsedUrl = tryParseAbsoluteUrl(url);
73
+ if (!parsedUrl) {
74
+ return new URL(`https://${url}`);
53
75
  }
54
- return url;
76
+ if (parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:" || parsedUrl.protocol === "file:") {
77
+ return parsedUrl;
78
+ }
79
+ if (isLikelyHostWithPort(parsedUrl, url)) {
80
+ return new URL(`https://${url}`);
81
+ }
82
+ throw new Error(
83
+ `Unsupported URL protocol: ${parsedUrl.protocol}. Use http://, https://, or file://.`
84
+ );
55
85
  }
56
86
  function normalizeDomain(url) {
57
- try {
58
- const u = new URL(normalizeUrl(url));
59
- return u.hostname.replace(/^www\./, "");
60
- } catch {
61
- return url.replace(/^www\./, "");
62
- }
87
+ return url.hostname.replace(/^www\./, "");
63
88
  }
64
89
  function getProfilePath(domain) {
65
90
  return join(PROFILES_DIR, `${domain}.json`);
@@ -265,7 +290,8 @@ function resolveWindowPosition(logger) {
265
290
  return void 0;
266
291
  }
267
292
  async function runOpen(rawUrl, headed, session, logger, options) {
268
- const url = normalizeUrl(rawUrl);
293
+ const parsedUrl = normalizeUrl(rawUrl);
294
+ const url = parsedUrl.href;
269
295
  const viewport = resolveViewport(options?.viewport, logger);
270
296
  const windowPosition = headed ? resolveWindowPosition(logger) : void 0;
271
297
  logger.info("open-start", { url, headed, session, viewport, windowPosition });
@@ -275,9 +301,10 @@ async function runOpen(rawUrl, headed, session, logger, options) {
275
301
  const networkLogPath = getSessionNetworkLogPath(session);
276
302
  const actionsLogPath = getSessionActionsLogPath(session);
277
303
  const browserMode = headed ? "headed" : "headless";
278
- const domain = normalizeDomain(url);
279
- const profilePath = getProfilePath(domain);
280
- const useProfile = hasProfile(domain);
304
+ const supportsSavedProfile = parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
305
+ const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : void 0;
306
+ const profilePath = domain ? getProfilePath(domain) : void 0;
307
+ const useProfile = domain ? hasProfile(domain) : false;
281
308
  logger.info("open-launching", {
282
309
  url,
283
310
  mode: browserMode,
@@ -291,9 +318,8 @@ async function runOpen(rawUrl, headed, session, logger, options) {
291
318
  console.log(`Loading saved profile for ${domain}`);
292
319
  }
293
320
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
294
- const escapedProfilePath = profilePath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
295
321
  const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
296
- const storageStateCode = useProfile ? `storageState: '${escapedProfilePath}',` : "";
322
+ const storageStateCode = useProfile ? `storageState: '${profilePath.replace(/\\/g, "\\\\").replace(/'/g, "\\'")}',` : "";
297
323
  const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
298
324
  const escapedNetworkLogPath = networkLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
299
325
  const escapedActionsLogPath = actionsLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
@@ -528,7 +554,7 @@ async function runSave(urlOrDomain, session, logger) {
528
554
  const { browser, context, page } = await connect(session, logger);
529
555
  try {
530
556
  await new Promise((r) => setTimeout(r, 500));
531
- const domain = normalizeDomain(urlOrDomain);
557
+ const domain = normalizeDomain(normalizeUrl(urlOrDomain));
532
558
  const profilePath = getProfilePath(domain);
533
559
  const cdpSession = await context.newCDPSession(page);
534
560
  const { cookies: rawCookies } = await cdpSession.send(
@@ -10,7 +10,11 @@ import {
10
10
  launchBrowser
11
11
  } from "../../index.js";
12
12
  import { parseSessionStateContent } from "../../shared/state/index.js";
13
- import { getProfilePath, normalizeDomain } from "../core/browser.js";
13
+ import {
14
+ getProfilePath,
15
+ normalizeDomain,
16
+ normalizeUrl
17
+ } from "../core/browser.js";
14
18
  import {
15
19
  getSessionActionsLogPath,
16
20
  getSessionNetworkLogPath,
@@ -68,18 +72,14 @@ async function waitForFailureSessionRelease(args) {
68
72
  );
69
73
  }
70
74
  }
71
- function resolveLocalAuthProfilePath(domain) {
72
- return getProfilePath(normalizeDomain(domain));
73
- }
74
75
  function getMissingLocalAuthProfileError(args) {
75
- const normalizedDomain = normalizeDomain(args.domain);
76
76
  return [
77
- `Local auth profile not found for domain "${normalizedDomain}".`,
77
+ `Local auth profile not found for domain "${args.normalizedDomain}".`,
78
78
  `Expected profile file: ${args.profilePath}`,
79
79
  "To create it:",
80
- ` 1. libretto open https://${normalizedDomain} --headed --session ${args.session}`,
80
+ ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
81
81
  " 2. Log in manually in the browser window.",
82
- ` 3. libretto save ${normalizedDomain} --session ${args.session}`
82
+ ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`
83
83
  ].join("\n");
84
84
  }
85
85
  function getAbsoluteIntegrationPath(integrationPath) {
@@ -138,11 +138,12 @@ async function runIntegrationInternal(args, options) {
138
138
  session: args.session
139
139
  });
140
140
  const authProfileDomain = args.authProfileDomain;
141
- const storageStatePath = authProfileDomain ? resolveLocalAuthProfilePath(authProfileDomain) : void 0;
142
- if (authProfileDomain && storageStatePath && !existsSync(storageStatePath)) {
141
+ const normalizedAuthProfileDomain = authProfileDomain ? normalizeDomain(normalizeUrl(authProfileDomain)) : void 0;
142
+ const storageStatePath = normalizedAuthProfileDomain ? getProfilePath(normalizedAuthProfileDomain) : void 0;
143
+ if (normalizedAuthProfileDomain && storageStatePath && !existsSync(storageStatePath)) {
143
144
  throw new Error(
144
145
  getMissingLocalAuthProfileError({
145
- domain: authProfileDomain,
146
+ normalizedDomain: normalizedAuthProfileDomain,
146
147
  profilePath: storageStatePath,
147
148
  session: args.session
148
149
  })
@@ -175,7 +176,6 @@ async function runIntegrationInternal(args, options) {
175
176
  });
176
177
  const workflowContext = {
177
178
  session: args.session,
178
- logger: integrationLogger,
179
179
  page: browserSession.page
180
180
  };
181
181
  try {
@@ -1,11 +1,9 @@
1
1
  import { Page } from 'playwright';
2
- import { MinimalLogger } from '../logger/logger.js';
3
2
 
4
3
  declare const LIBRETTO_WORKFLOW_BRAND: unique symbol;
5
4
  type LibrettoWorkflowContext = {
6
5
  session: string;
7
6
  page: Page;
8
- logger: MinimalLogger;
9
7
  };
10
8
  type LibrettoWorkflowHandler<Input = unknown, Output = unknown> = (ctx: LibrettoWorkflowContext, input: Input) => Promise<Output>;
11
9
  declare class LibrettoWorkflow<Input = unknown, Output = unknown> {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "libretto",
3
- "version": "0.5.4",
3
+ "version": "0.5.5",
4
4
  "description": "AI-powered browser automation library and CLI built on Playwright",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -8,7 +8,6 @@
8
8
  "url": "https://github.com/saffron-health/libretto"
9
9
  },
10
10
  "type": "module",
11
- "packageManager": "pnpm@9.15.4",
12
11
  "publishConfig": {
13
12
  "access": "public"
14
13
  },
@@ -29,20 +28,6 @@
29
28
  "default": "./dist/index.js"
30
29
  }
31
30
  },
32
- "scripts": {
33
- "postinstall": "node scripts/postinstall.mjs",
34
- "sync:mirrors": "node ../dev-tools/scripts/sync-mirrors.mjs",
35
- "check:mirrors": "node ../dev-tools/scripts/check-mirrors-sync.mjs",
36
- "sync-skills": "pnpm run sync:mirrors",
37
- "check:skills": "pnpm run check:mirrors",
38
- "build": "tsup --config tsup.config.ts",
39
- "type-check": "tsc --noEmit",
40
- "test": "pnpm run build && vitest run",
41
- "test:watch": "vitest",
42
- "cli": "node dist/index.js",
43
- "generate-changelog": "tsx scripts/generate-changelog.ts",
44
- "prepack": "pnpm run build"
45
- },
46
31
  "peerDependencies": {
47
32
  "@ai-sdk/anthropic": "^3.0.58",
48
33
  "@ai-sdk/google": "^3.0.51",
@@ -87,5 +72,18 @@
87
72
  "playwright": "^1.58.2",
88
73
  "tsx": "^4.21.0",
89
74
  "zod": "^4.3.6"
75
+ },
76
+ "scripts": {
77
+ "postinstall": "node scripts/postinstall.mjs",
78
+ "sync:mirrors": "node ../dev-tools/scripts/sync-mirrors.mjs",
79
+ "check:mirrors": "node ../dev-tools/scripts/check-mirrors-sync.mjs",
80
+ "sync-skills": "pnpm run sync:mirrors",
81
+ "check:skills": "pnpm run check:mirrors",
82
+ "build": "tsup --config tsup.config.ts",
83
+ "type-check": "tsc --noEmit",
84
+ "test": "pnpm run build && vitest run",
85
+ "test:watch": "vitest",
86
+ "cli": "node dist/index.js",
87
+ "generate-changelog": "tsx scripts/generate-changelog.ts"
90
88
  }
91
- }
89
+ }
@@ -25,9 +25,9 @@ type Output = {
25
25
  export const myWorkflow = workflow<Input, Output>(
26
26
  "myWorkflow",
27
27
  async (ctx: LibrettoWorkflowContext, input): Promise<Output> => {
28
- const { session, page, logger } = ctx;
28
+ const { session, page } = ctx;
29
29
 
30
- logger.info("workflow-start", { session, query: input.query });
30
+ console.log("workflow-start", { session, query: input.query });
31
31
  await page.goto("https://example.com");
32
32
  await pause(session);
33
33
 
@@ -40,7 +40,7 @@ Key points:
40
40
 
41
41
  - `workflow(name, handler)` takes a unique workflow name and returns the workflow object that Libretto can run.
42
42
  - `npx libretto run ./file.ts myWorkflow` resolves `myWorkflow` from the workflows exported by `./file.ts`, so export or re-export the workflow from that file directly or through a `workflows` object, and make sure the run argument matches the name passed to `workflow("myWorkflow", ...)`.
43
- - `ctx` provides `session`, `page`, and `logger`
43
+ - `ctx` provides `session` and `page`. Use `console.log`/`console.warn`/`console.error` for logging — the runtime wraps these with structured metadata automatically.
44
44
  - `input` comes from `--params '{"query":"foo"}'` or `--params-file params.json` on the CLI
45
45
  - Use `await pause(ctx.session)` (or `await pause(session)`) to pause the workflow for debugging. It is a no-op in production.
46
46
  - After validation is complete and the workflow is confirmed working end to end, remove all `pause()` calls and pause-only workflow params unless the user explicitly says to keep them.
@@ -56,20 +56,61 @@ async function pickFreePort(): Promise<number> {
56
56
  });
57
57
  }
58
58
 
59
- export function normalizeUrl(url: string): string {
60
- if (!/^https?:\/\//i.test(url)) {
61
- return `https://${url}`;
59
+ function tryParseAbsoluteUrl(url: string): URL | null {
60
+ try {
61
+ return new URL(url);
62
+ } catch {
63
+ return null;
62
64
  }
63
- return url;
64
65
  }
65
66
 
66
- export function normalizeDomain(url: string): string {
67
- try {
68
- const u = new URL(normalizeUrl(url));
69
- return u.hostname.replace(/^www\./, "");
70
- } catch {
71
- return url.replace(/^www\./, "");
67
+ function isLikelyHostWithPort(parsedUrl: URL, rawUrl: string): boolean {
68
+ // `new URL("localhost:3000")` parses successfully, but treats `localhost:`
69
+ // as a custom scheme instead of a bare host with port. Detect that shape so
70
+ // CLI shorthand like `libretto open localhost:3000` still normalizes to
71
+ // `https://localhost:3000/`.
72
+ const remainder = rawUrl.slice(parsedUrl.protocol.length);
73
+ if (remainder.length === 0) return false;
74
+
75
+ let index = 0;
76
+ while (index < remainder.length) {
77
+ const charCode = remainder.charCodeAt(index);
78
+ if (charCode < 48 || charCode > 57) break;
79
+ index += 1;
72
80
  }
81
+
82
+ if (index === 0) return false;
83
+ if (index === remainder.length) return true;
84
+
85
+ const nextChar = remainder[index];
86
+ return nextChar === "/" || nextChar === "?" || nextChar === "#";
87
+ }
88
+
89
+ export function normalizeUrl(url: string): URL {
90
+ const parsedUrl = tryParseAbsoluteUrl(url);
91
+ if (!parsedUrl) {
92
+ return new URL(`https://${url}`);
93
+ }
94
+
95
+ if (
96
+ parsedUrl.protocol === "http:" ||
97
+ parsedUrl.protocol === "https:" ||
98
+ parsedUrl.protocol === "file:"
99
+ ) {
100
+ return parsedUrl;
101
+ }
102
+
103
+ if (isLikelyHostWithPort(parsedUrl, url)) {
104
+ return new URL(`https://${url}`);
105
+ }
106
+
107
+ throw new Error(
108
+ `Unsupported URL protocol: ${parsedUrl.protocol}. Use http://, https://, or file://.`,
109
+ );
110
+ }
111
+
112
+ export function normalizeDomain(url: URL): string {
113
+ return url.hostname.replace(/^www\./, "");
73
114
  }
74
115
 
75
116
  export function getProfilePath(domain: string): string {
@@ -359,7 +400,8 @@ export async function runOpen(
359
400
  logger: LoggerApi,
360
401
  options?: { viewport?: { width: number; height: number } },
361
402
  ): Promise<void> {
362
- const url = normalizeUrl(rawUrl);
403
+ const parsedUrl = normalizeUrl(rawUrl);
404
+ const url = parsedUrl.href;
363
405
  const viewport = resolveViewport(options?.viewport, logger);
364
406
  const windowPosition = headed ? resolveWindowPosition(logger) : undefined;
365
407
  logger.info("open-start", { url, headed, session, viewport, windowPosition });
@@ -371,9 +413,11 @@ export async function runOpen(
371
413
  const actionsLogPath = getSessionActionsLogPath(session);
372
414
 
373
415
  const browserMode = headed ? "headed" : "headless";
374
- const domain = normalizeDomain(url);
375
- const profilePath = getProfilePath(domain);
376
- const useProfile = hasProfile(domain);
416
+ const supportsSavedProfile =
417
+ parsedUrl.protocol === "http:" || parsedUrl.protocol === "https:";
418
+ const domain = supportsSavedProfile ? normalizeDomain(parsedUrl) : undefined;
419
+ const profilePath = domain ? getProfilePath(domain) : undefined;
420
+ const useProfile = domain ? hasProfile(domain) : false;
377
421
 
378
422
  logger.info("open-launching", {
379
423
  url,
@@ -390,12 +434,11 @@ export async function runOpen(
390
434
  }
391
435
  console.log(`Launching ${browserMode} browser (session: ${session})...`);
392
436
 
393
- const escapedProfilePath = profilePath
394
- .replace(/\\/g, "\\\\")
395
- .replace(/'/g, "\\'");
396
437
  const escapedUrl = url.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
397
438
  const storageStateCode = useProfile
398
- ? `storageState: '${escapedProfilePath}',`
439
+ ? `storageState: '${profilePath!
440
+ .replace(/\\/g, "\\\\")
441
+ .replace(/'/g, "\\'")}',`
399
442
  : "";
400
443
 
401
444
  const escapedLogPath = runLogPath.replace(/\\/g, "\\\\").replace(/'/g, "\\'");
@@ -671,7 +714,7 @@ export async function runSave(
671
714
  try {
672
715
  await new Promise((r) => setTimeout(r, 500));
673
716
 
674
- const domain = normalizeDomain(urlOrDomain);
717
+ const domain = normalizeDomain(normalizeUrl(urlOrDomain));
675
718
  const profilePath = getProfilePath(domain);
676
719
 
677
720
  const cdpSession = await context.newCDPSession(page);
@@ -14,7 +14,11 @@ import {
14
14
  } from "../../index.js";
15
15
  import type { LoggerApi } from "../../shared/logger/index.js";
16
16
  import { parseSessionStateContent } from "../../shared/state/index.js";
17
- import { getProfilePath, normalizeDomain } from "../core/browser.js";
17
+ import {
18
+ getProfilePath,
19
+ normalizeDomain,
20
+ normalizeUrl,
21
+ } from "../core/browser.js";
18
22
  import {
19
23
  getSessionActionsLogPath,
20
24
  getSessionDir,
@@ -109,23 +113,18 @@ async function waitForFailureSessionRelease(args: {
109
113
  }
110
114
  }
111
115
 
112
- function resolveLocalAuthProfilePath(domain: string): string {
113
- return getProfilePath(normalizeDomain(domain));
114
- }
115
-
116
116
  function getMissingLocalAuthProfileError(args: {
117
- domain: string;
117
+ normalizedDomain: string;
118
118
  profilePath: string;
119
119
  session: string;
120
120
  }): string {
121
- const normalizedDomain = normalizeDomain(args.domain);
122
121
  return [
123
- `Local auth profile not found for domain "${normalizedDomain}".`,
122
+ `Local auth profile not found for domain "${args.normalizedDomain}".`,
124
123
  `Expected profile file: ${args.profilePath}`,
125
124
  "To create it:",
126
- ` 1. libretto open https://${normalizedDomain} --headed --session ${args.session}`,
125
+ ` 1. libretto open https://${args.normalizedDomain} --headed --session ${args.session}`,
127
126
  " 2. Log in manually in the browser window.",
128
- ` 3. libretto save ${normalizedDomain} --session ${args.session}`,
127
+ ` 3. libretto save ${args.normalizedDomain} --session ${args.session}`,
129
128
  ].join("\n");
130
129
  }
131
130
 
@@ -215,13 +214,20 @@ async function runIntegrationInternal(
215
214
 
216
215
  // Resolve auth profile from CLI flag (--auth-profile <domain>)
217
216
  const authProfileDomain = args.authProfileDomain;
218
- const storageStatePath = authProfileDomain
219
- ? resolveLocalAuthProfilePath(authProfileDomain)
217
+ const normalizedAuthProfileDomain = authProfileDomain
218
+ ? normalizeDomain(normalizeUrl(authProfileDomain))
219
+ : undefined;
220
+ const storageStatePath = normalizedAuthProfileDomain
221
+ ? getProfilePath(normalizedAuthProfileDomain)
220
222
  : undefined;
221
- if (authProfileDomain && storageStatePath && !existsSync(storageStatePath)) {
223
+ if (
224
+ normalizedAuthProfileDomain &&
225
+ storageStatePath &&
226
+ !existsSync(storageStatePath)
227
+ ) {
222
228
  throw new Error(
223
229
  getMissingLocalAuthProfileError({
224
- domain: authProfileDomain,
230
+ normalizedDomain: normalizedAuthProfileDomain,
225
231
  profilePath: storageStatePath,
226
232
  session: args.session,
227
233
  }),
@@ -255,7 +261,6 @@ async function runIntegrationInternal(
255
261
 
256
262
  const workflowContext: LibrettoWorkflowContext = {
257
263
  session: args.session,
258
- logger: integrationLogger,
259
264
  page: browserSession.page,
260
265
  };
261
266
 
@@ -1,12 +1,10 @@
1
1
  import type { Page } from "playwright";
2
- import type { MinimalLogger } from "../logger/logger.js";
3
2
 
4
3
  export const LIBRETTO_WORKFLOW_BRAND = Symbol.for("libretto.workflow");
5
4
 
6
5
  export type LibrettoWorkflowContext = {
7
6
  session: string;
8
7
  page: Page;
9
- logger: MinimalLogger;
10
8
  };
11
9
 
12
10
  export type LibrettoWorkflowHandler<Input = unknown, Output = unknown> = (