libretto 0.6.7-experimental.0 → 0.6.8

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.
@@ -13,6 +13,8 @@ export function createLibrettoCloudProvider(): ProviderApi {
13
13
  );
14
14
  const endpoint = apiUrl.replace(/\/$/, "");
15
15
 
16
+ // The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
17
+ // must be wrapped as { json: ... } and outputs arrive the same way.
16
18
  return {
17
19
  async createSession() {
18
20
  const timeoutSeconds = Number(
@@ -24,7 +26,9 @@ export function createLibrettoCloudProvider(): ProviderApi {
24
26
  "x-api-key": apiKey,
25
27
  "Content-Type": "application/json",
26
28
  },
27
- body: JSON.stringify({ timeout_seconds: timeoutSeconds }),
29
+ body: JSON.stringify({
30
+ json: { timeout_seconds: timeoutSeconds },
31
+ }),
28
32
  });
29
33
  if (!resp.ok) {
30
34
  const body = await resp.text();
@@ -32,9 +36,8 @@ export function createLibrettoCloudProvider(): ProviderApi {
32
36
  `Libretto Cloud API error (${resp.status}): ${body}`,
33
37
  );
34
38
  }
35
- const json = (await resp.json()) as {
36
- session_id: string;
37
- cdp_url: string;
39
+ const { json } = (await resp.json()) as {
40
+ json: { session_id: string; cdp_url: string };
38
41
  };
39
42
  return {
40
43
  sessionId: json.session_id,
@@ -48,7 +51,7 @@ export function createLibrettoCloudProvider(): ProviderApi {
48
51
  "x-api-key": apiKey,
49
52
  "Content-Type": "application/json",
50
53
  },
51
- body: JSON.stringify({ session_id: sessionId }),
54
+ body: JSON.stringify({ json: { session_id: sessionId } }),
52
55
  });
53
56
  if (!resp.ok) {
54
57
  const body = await resp.text();
@@ -1,6 +1,6 @@
1
1
  import type { LanguageModel } from "ai";
2
2
 
3
- export type Provider = "google" | "vertex" | "anthropic" | "openai";
3
+ export type Provider = "google" | "vertex" | "anthropic" | "openai" | "openrouter";
4
4
 
5
5
  const GEMINI_API_KEY_ENV_VARS = [
6
6
  "GEMINI_API_KEY",
@@ -19,6 +19,7 @@ const SUPPORTED_PROVIDER_ALIASES = {
19
19
  anthropic: "anthropic",
20
20
  codex: "openai",
21
21
  openai: "openai",
22
+ openrouter: "openrouter",
22
23
  } as const satisfies Record<string, Provider>;
23
24
 
24
25
  function readFirstEnvValue(
@@ -51,7 +52,7 @@ export function parseModel(model: string): {
51
52
 
52
53
  if (!provider) {
53
54
  throw new Error(
54
- `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), and vertex.`,
55
+ `Unsupported provider "${providerInput}". Supported providers: openai/codex, anthropic, google (Gemini API), vertex, and openrouter.`,
55
56
  );
56
57
  }
57
58
 
@@ -71,6 +72,8 @@ export function hasProviderCredentials(
71
72
  return Boolean(env.ANTHROPIC_API_KEY?.trim());
72
73
  case "openai":
73
74
  return Boolean(env.OPENAI_API_KEY?.trim());
75
+ case "openrouter":
76
+ return Boolean(env.OPENROUTER_API_KEY?.trim());
74
77
  }
75
78
  }
76
79
 
@@ -86,6 +89,9 @@ export function missingProviderCredentialsMessage(provider: Provider): string {
86
89
  case "openai": {
87
90
  return "OpenAI API key is missing. Set OPENAI_API_KEY.";
88
91
  }
92
+ case "openrouter": {
93
+ return "OpenRouter API key is missing. Set OPENROUTER_API_KEY.";
94
+ }
89
95
  }
90
96
  }
91
97
 
@@ -133,6 +139,18 @@ async function getProviderModel(
133
139
  const openai = createOpenAI({ apiKey });
134
140
  return openai(modelId);
135
141
  }
142
+ case "openrouter": {
143
+ const apiKey = process.env.OPENROUTER_API_KEY?.trim();
144
+ if (!apiKey) {
145
+ throw new Error(missingProviderCredentialsMessage(provider));
146
+ }
147
+ const { createOpenAI } = await import("@ai-sdk/openai");
148
+ const openrouter = createOpenAI({
149
+ apiKey,
150
+ baseURL: "https://openrouter.ai/api/v1",
151
+ });
152
+ return openrouter(modelId);
153
+ }
136
154
  }
137
155
  }
138
156
 
@@ -4,7 +4,7 @@ import { mkdir, writeFile } from "node:fs/promises";
4
4
  import { cwd } from "node:process";
5
5
  import { isAbsolute, resolve } from "node:path";
6
6
  import { pathToFileURL } from "node:url";
7
- import { loadProjectEnv } from "../../shared/env/load-env.js";
7
+ import { loadEnv } from "../../shared/env/load-env.js";
8
8
  import {
9
9
  getDefaultWorkflowFromModuleExports,
10
10
  getWorkflowsFromModuleExports,
@@ -196,7 +196,7 @@ async function runIntegrationInternal(
196
196
  const { logger } = options;
197
197
  const absolutePath = getAbsoluteIntegrationPath(args.integrationPath);
198
198
 
199
- const envPath = loadProjectEnv(absolutePath);
199
+ const envPath = loadEnv();
200
200
  if (envPath) {
201
201
  logger.info("loaded-env", { path: envPath });
202
202
  }
@@ -269,6 +269,16 @@ async function runIntegrationInternal(
269
269
  },
270
270
  });
271
271
 
272
+ // tsx/esbuild injects __name() wrappers when keepNames is true. Playwright
273
+ // serializes callbacks via Function#toString() into the browser context which
274
+ // lacks __name, causing ReferenceError. Inject a no-op polyfill into every page.
275
+ await browserSession.context.addInitScript(() => {
276
+ (globalThis as Record<string, unknown>).__name = (
277
+ target: unknown,
278
+ value: string,
279
+ ) => Object.defineProperty(target as object, "name", { value, configurable: true });
280
+ });
281
+
272
282
  const workflowContext: LibrettoWorkflowContext = {
273
283
  session: args.session,
274
284
  page: browserSession.page,
@@ -58,7 +58,6 @@ export function isObfuscatedClass(cls: string): boolean {
58
58
  if (cls.length > 80) return true;
59
59
  if (/^_?[0-9a-f]{6,}$/i.test(cls)) return true;
60
60
  if (/^[a-z]+_[0-9a-f]{4,}$/i.test(cls)) return true;
61
- if (/^[a-z]{1,2}[0-9]{2,}$/i.test(cls)) return true;
62
61
 
63
62
  const digits = (cls.match(/[0-9]/g) || []).length;
64
63
  const letters = (cls.match(/[a-zA-Z]/g) || []).length;
@@ -1,69 +1,91 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { dirname, join } from "node:path";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { resolveLibrettoRepoRoot } from "../paths/repo-root.js";
3
4
 
4
- /**
5
- * Walk up from `startDir` until a `.env` file is found.
6
- * Returns the full path to the `.env`, or `null` if the filesystem root is reached.
7
- */
8
- function findNearestEnv(startDir: string): string | null {
9
- let dir = startDir;
10
- while (true) {
11
- const envPath = join(dir, ".env");
12
- if (existsSync(envPath)) return envPath;
13
- const parent = dirname(dir);
14
- if (parent === dir) return null; // filesystem root
15
- dir = parent;
5
+ const REPO_ROOT = resolveLibrettoRepoRoot();
6
+
7
+ export function parseDotEnvAssignment(
8
+ line: string,
9
+ ): { key: string; value: string } | null {
10
+ const trimmed = line.trim();
11
+ if (!trimmed || trimmed.startsWith("#")) return null;
12
+
13
+ const withoutExport = trimmed.startsWith("export ")
14
+ ? trimmed.slice("export ".length).trimStart()
15
+ : trimmed;
16
+ const eqIdx = withoutExport.indexOf("=");
17
+ if (eqIdx < 1) return null;
18
+
19
+ const key = withoutExport.slice(0, eqIdx).trim();
20
+ if (!key) return null;
21
+
22
+ const rawValue = withoutExport.slice(eqIdx + 1).trimStart();
23
+ if (!rawValue) {
24
+ return { key, value: "" };
16
25
  }
17
- }
18
26
 
19
- /**
20
- * Parse a `.env` file into key-value pairs.
21
- * Handles KEY=VALUE, KEY="VALUE", KEY='VALUE', comments (#), and blank lines.
22
- * Does not support multiline values or variable interpolation.
23
- */
24
- function parseEnvFile(content: string): Record<string, string> {
25
- const vars: Record<string, string> = {};
26
- for (const raw of content.split("\n")) {
27
- const line = raw.trim();
28
- if (!line || line.startsWith("#")) continue;
29
- const withoutExport = line.startsWith("export ")
30
- ? line.slice("export ".length).trimStart()
31
- : line;
32
- const eqIndex = withoutExport.indexOf("=");
33
- if (eqIndex === -1) continue;
34
- const key = withoutExport.slice(0, eqIndex).trim();
35
- let value = withoutExport.slice(eqIndex + 1).trim();
36
- // Strip matching quotes
37
- if (
38
- (value.startsWith('"') && value.endsWith('"')) ||
39
- (value.startsWith("'") && value.endsWith("'"))
40
- ) {
41
- value = value.slice(1, -1);
42
- } else {
43
- // Strip inline comments from unquoted values
44
- const commentIndex = value.search(/\s#/);
45
- if (commentIndex >= 0) {
46
- value = value.slice(0, commentIndex).trimEnd();
47
- }
27
+ if (rawValue.startsWith('"')) {
28
+ const closeIdx = rawValue.indexOf('"', 1);
29
+ if (closeIdx > 0) {
30
+ return { key, value: rawValue.slice(1, closeIdx) };
48
31
  }
49
- vars[key] = value;
32
+ return { key, value: rawValue.slice(1) };
33
+ }
34
+
35
+ if (rawValue.startsWith("'")) {
36
+ const closeIdx = rawValue.indexOf("'", 1);
37
+ if (closeIdx > 0) {
38
+ return { key, value: rawValue.slice(1, closeIdx) };
39
+ }
40
+ return { key, value: rawValue.slice(1) };
41
+ }
42
+
43
+ const inlineCommentIndex = rawValue.search(/\s#/);
44
+ const value =
45
+ inlineCommentIndex >= 0
46
+ ? rawValue.slice(0, inlineCommentIndex).trimEnd()
47
+ : rawValue.trim();
48
+ return { key, value };
49
+ }
50
+
51
+ function readWorktreeEnvPath(): string | null {
52
+ const gitPath = join(REPO_ROOT, ".git");
53
+ if (!existsSync(gitPath)) return null;
54
+
55
+ try {
56
+ const gitPointer = readFileSync(gitPath, "utf-8").trim();
57
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
58
+ if (!match?.[1]) return null;
59
+ const worktreeGitDir = resolve(REPO_ROOT, match[1].trim());
60
+ const commonGitDir = resolve(worktreeGitDir, "..", "..");
61
+ return join(dirname(commonGitDir), ".env");
62
+ } catch {
63
+ return null;
50
64
  }
51
- return vars;
52
65
  }
53
66
 
54
67
  /**
55
- * Load the nearest `.env` file above `scriptPath`.
56
- * Existing `process.env` values are never overridden.
68
+ * Load the `.env` file at the repository root into `process.env`.
69
+ * Existing values are never overridden.
70
+ * Respects `LIBRETTO_DISABLE_DOTENV=1` to skip loading entirely.
57
71
  * Returns the path of the loaded `.env`, or `null` if none was found.
58
72
  */
59
- export function loadProjectEnv(scriptPath: string): string | null {
60
- const envPath = findNearestEnv(dirname(scriptPath));
73
+ export function loadEnv(): string | null {
74
+ if (process.env.LIBRETTO_DISABLE_DOTENV?.trim() === "1") return null;
75
+
76
+ const envPathCandidates = [
77
+ join(REPO_ROOT, ".env"),
78
+ readWorktreeEnvPath(),
79
+ ].filter((value): value is string => Boolean(value));
80
+
81
+ const envPath = envPathCandidates.find((candidate) => existsSync(candidate));
61
82
  if (!envPath) return null;
62
83
 
63
- const vars = parseEnvFile(readFileSync(envPath, "utf8"));
64
- for (const [key, value] of Object.entries(vars)) {
65
- if (process.env[key] === undefined) {
66
- process.env[key] = value;
84
+ for (const line of readFileSync(envPath, "utf-8").split("\n")) {
85
+ const parsed = parseDotEnvAssignment(line);
86
+ if (!parsed) continue;
87
+ if (!(parsed.key in process.env)) {
88
+ process.env[parsed.key] = parsed.value;
67
89
  }
68
90
  }
69
91
  return envPath;