libretto 0.6.9 → 0.6.11

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 (60) hide show
  1. package/dist/cli/cli.js +2 -0
  2. package/dist/cli/commands/auth.js +535 -0
  3. package/dist/cli/commands/billing.js +74 -0
  4. package/dist/cli/commands/browser.js +8 -3
  5. package/dist/cli/commands/deploy.js +2 -7
  6. package/dist/cli/commands/execution.js +99 -136
  7. package/dist/cli/commands/snapshot.js +38 -126
  8. package/dist/cli/core/ai-model.js +0 -3
  9. package/dist/cli/core/auth-fetch.js +195 -0
  10. package/dist/cli/core/auth-storage.js +52 -0
  11. package/dist/cli/core/browser.js +128 -202
  12. package/dist/cli/core/daemon/config.js +6 -0
  13. package/dist/cli/core/daemon/daemon.js +298 -0
  14. package/dist/cli/core/daemon/exec.js +86 -0
  15. package/dist/cli/core/daemon/index.js +16 -0
  16. package/dist/cli/core/daemon/ipc.js +171 -0
  17. package/dist/cli/core/daemon/pages.js +15 -0
  18. package/dist/cli/core/daemon/snapshot.js +86 -0
  19. package/dist/cli/core/daemon/spawn.js +90 -0
  20. package/dist/cli/core/exec-compiler.js +111 -0
  21. package/dist/cli/core/prompt.js +72 -0
  22. package/dist/cli/core/providers/libretto-cloud.js +2 -6
  23. package/dist/cli/core/readonly-exec.js +1 -1
  24. package/dist/cli/router.js +4 -0
  25. package/dist/cli/workers/run-integration-runtime.js +0 -5
  26. package/dist/shared/state/session-state.d.ts +1 -0
  27. package/dist/shared/state/session-state.js +2 -1
  28. package/docs/browser-automation-approaches.md +435 -0
  29. package/docs/releasing.md +117 -0
  30. package/package.json +4 -3
  31. package/skills/libretto/SKILL.md +14 -1
  32. package/skills/libretto-readonly/SKILL.md +1 -1
  33. package/src/cli/cli.ts +2 -0
  34. package/src/cli/commands/auth.ts +787 -0
  35. package/src/cli/commands/billing.ts +133 -0
  36. package/src/cli/commands/browser.ts +8 -2
  37. package/src/cli/commands/deploy.ts +2 -7
  38. package/src/cli/commands/execution.ts +126 -186
  39. package/src/cli/commands/snapshot.ts +46 -143
  40. package/src/cli/core/ai-model.ts +4 -5
  41. package/src/cli/core/auth-fetch.ts +283 -0
  42. package/src/cli/core/auth-storage.ts +102 -0
  43. package/src/cli/core/browser.ts +159 -242
  44. package/src/cli/core/daemon/config.ts +46 -0
  45. package/src/cli/core/daemon/daemon.ts +429 -0
  46. package/src/cli/core/daemon/exec.ts +128 -0
  47. package/src/cli/core/daemon/index.ts +24 -0
  48. package/src/cli/core/daemon/ipc.ts +294 -0
  49. package/src/cli/core/daemon/pages.ts +21 -0
  50. package/src/cli/core/daemon/snapshot.ts +114 -0
  51. package/src/cli/core/daemon/spawn.ts +171 -0
  52. package/src/cli/core/exec-compiler.ts +169 -0
  53. package/src/cli/core/prompt.ts +94 -0
  54. package/src/cli/core/providers/libretto-cloud.ts +2 -6
  55. package/src/cli/core/readonly-exec.ts +2 -1
  56. package/src/cli/router.ts +4 -0
  57. package/src/cli/workers/run-integration-runtime.ts +0 -6
  58. package/src/shared/state/session-state.ts +1 -0
  59. package/dist/cli/core/browser-daemon.js +0 -122
  60. package/src/cli/core/browser-daemon.ts +0 -198
@@ -1,8 +1,6 @@
1
- import { mkdirSync } from "node:fs";
1
+ import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { z } from "zod";
3
3
  import type { LoggerApi } from "../../shared/logger/index.js";
4
- import { connect, disconnectBrowser } from "../core/browser.js";
5
- import { getSessionSnapshotRunDir } from "../core/context.js";
6
4
  import { condenseDom } from "../../shared/condense-dom/condense-dom.js";
7
5
  import { readSessionState } from "../core/session.js";
8
6
  import {
@@ -14,12 +12,9 @@ import { pageOption, sessionOption, withRequiredSession } from "./shared.js";
14
12
  import { runApiInterpret } from "../core/api-snapshot-analyzer.js";
15
13
  import { readSnapshotModel } from "../core/config.js";
16
14
  import { resolveSnapshotApiModelOrThrow } from "../core/ai-model.js";
15
+ import { DaemonClient } from "../core/daemon/index.js";
17
16
 
18
- const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
19
-
20
- function generateSnapshotRunId(): string {
21
- return `snapshot-${Date.now()}`;
22
- }
17
+ export const FALLBACK_SNAPSHOT_VIEWPORT = { width: 1280, height: 800 } as const;
23
18
 
24
19
  type SnapshotViewportMetrics = {
25
20
  configuredWidth: number | null;
@@ -28,11 +23,11 @@ type SnapshotViewportMetrics = {
28
23
  innerHeight: number | null;
29
24
  };
30
25
 
31
- function isZeroViewport(value: number | null): boolean {
26
+ export function isZeroViewport(value: number | null): boolean {
32
27
  return typeof value === "number" && value <= 0;
33
28
  }
34
29
 
35
- function shouldForceSnapshotViewport(
30
+ export function shouldForceSnapshotViewport(
36
31
  metrics: SnapshotViewportMetrics,
37
32
  ): boolean {
38
33
  return (
@@ -43,14 +38,14 @@ function shouldForceSnapshotViewport(
43
38
  );
44
39
  }
45
40
 
46
- function isZeroWidthScreenshotError(error: unknown): boolean {
41
+ export function isZeroWidthScreenshotError(error: unknown): boolean {
47
42
  return (
48
43
  error instanceof Error &&
49
44
  error.message.includes("Cannot take screenshot with 0 width")
50
45
  );
51
46
  }
52
47
 
53
- async function readSnapshotViewportMetrics(page: {
48
+ export async function readSnapshotViewportMetrics(page: {
54
49
  viewportSize(): { width: number; height: number } | null;
55
50
  evaluate<T>(pageFunction: () => T | Promise<T>): Promise<T>;
56
51
  }): Promise<SnapshotViewportMetrics> {
@@ -75,7 +70,7 @@ async function readSnapshotViewportMetrics(page: {
75
70
  };
76
71
  }
77
72
 
78
- function resolveSnapshotViewport(
73
+ export function resolveSnapshotViewport(
79
74
  session: string,
80
75
  logger: LoggerApi,
81
76
  ): { width: number; height: number } {
@@ -95,7 +90,7 @@ function resolveSnapshotViewport(
95
90
  return FALLBACK_SNAPSHOT_VIEWPORT;
96
91
  }
97
92
 
98
- async function forceSnapshotViewport(
93
+ export async function forceSnapshotViewport(
99
94
  page: {
100
95
  setViewportSize(size: { width: number; height: number }): Promise<void>;
101
96
  },
@@ -114,136 +109,39 @@ async function forceSnapshotViewport(
114
109
  });
115
110
  }
116
111
 
117
- async function captureScreenshot(
112
+ async function captureSnapshot(
118
113
  session: string,
119
114
  logger: LoggerApi,
115
+ daemonSocketPath: string,
120
116
  pageId?: string,
121
117
  ): Promise<ScreenshotPair> {
122
- logger.info("screenshot-start", { session, pageId });
123
- const snapshotRunId = generateSnapshotRunId();
124
- const snapshotRunDir = getSessionSnapshotRunDir(session, snapshotRunId);
125
- mkdirSync(snapshotRunDir, { recursive: true });
126
- const { browser, page } = await connect(session, logger, 10000, {
127
- pageId,
128
- requireSinglePage: true,
118
+ logger.info("snapshot-via-daemon", { session, pageId });
119
+ const client = new DaemonClient(daemonSocketPath);
120
+ const { pngPath, htmlPath, snapshotRunId, pageUrl, title } =
121
+ await client.snapshot({ pageId });
122
+
123
+ // condenseDom runs in the CLI process, not the daemon.
124
+ const htmlContent = readFileSync(htmlPath, "utf8");
125
+ const condenseResult = condenseDom(htmlContent);
126
+ const condensedHtmlPath = htmlPath.replace(/\.html$/, ".condensed.html");
127
+ writeFileSync(condensedHtmlPath, condenseResult.html);
128
+
129
+ logger.info("snapshot-daemon-success", {
130
+ session,
131
+ pageUrl,
132
+ title,
133
+ pngPath,
134
+ htmlPath,
135
+ condensedHtmlPath,
136
+ snapshotRunId,
137
+ domCondenseStats: {
138
+ originalLength: condenseResult.originalLength,
139
+ condensedLength: condenseResult.condensedLength,
140
+ reductions: condenseResult.reductions,
141
+ },
129
142
  });
130
143
 
131
- try {
132
- let title: string | null = null;
133
- try {
134
- title = await page.title();
135
- } catch (error) {
136
- logger.warn("screenshot-title-read-failed", {
137
- session,
138
- pageId,
139
- error,
140
- });
141
- }
142
-
143
- let pageUrl: string | null = null;
144
- try {
145
- pageUrl = page.url();
146
- } catch (error) {
147
- logger.warn("screenshot-url-read-failed", {
148
- session,
149
- pageId,
150
- error,
151
- });
152
- }
153
-
154
- const pngPath = `${snapshotRunDir}/page.png`;
155
- const htmlPath = `${snapshotRunDir}/page.html`;
156
- const condensedHtmlPath = `${snapshotRunDir}/page.condensed.html`;
157
-
158
- const RENDER_SETTLE_TIMEOUT_MS = 10_000;
159
- await Promise.race([
160
- page.waitForLoadState("networkidle").catch(() => {}),
161
- new Promise((resolve) => setTimeout(resolve, RENDER_SETTLE_TIMEOUT_MS)),
162
- ]);
163
-
164
- const restoreViewport = resolveSnapshotViewport(session, logger);
165
- const viewportMetrics = await readSnapshotViewportMetrics(page);
166
- logger.info("screenshot-viewport-metrics", {
167
- session,
168
- pageId,
169
- restoreViewport,
170
- ...viewportMetrics,
171
- });
172
- await forceSnapshotViewport(
173
- page,
174
- restoreViewport,
175
- logger,
176
- session,
177
- pageId,
178
- shouldForceSnapshotViewport(viewportMetrics)
179
- ? "preflight-invalid-viewport"
180
- : "preflight-normalize-viewport",
181
- );
182
-
183
- try {
184
- await page.screenshot({ path: pngPath });
185
- } catch (error) {
186
- if (!isZeroWidthScreenshotError(error)) {
187
- throw error;
188
- }
189
- await forceSnapshotViewport(
190
- page,
191
- restoreViewport,
192
- logger,
193
- session,
194
- pageId,
195
- "retry-after-zero-width-screenshot-error",
196
- );
197
- await page.screenshot({ path: pngPath });
198
- }
199
-
200
- const htmlContent = await page.content();
201
- const fs = await import("node:fs/promises");
202
- await fs.writeFile(htmlPath, htmlContent);
203
-
204
- // Write condensed DOM
205
- const condenseResult = condenseDom(htmlContent);
206
- await fs.writeFile(condensedHtmlPath, condenseResult.html);
207
-
208
- logger.info("screenshot-success", {
209
- session,
210
- pageUrl,
211
- title,
212
- pngPath,
213
- htmlPath,
214
- condensedHtmlPath,
215
- snapshotRunId,
216
- domCondenseStats: {
217
- originalLength: condenseResult.originalLength,
218
- condensedLength: condenseResult.condensedLength,
219
- reductions: condenseResult.reductions,
220
- },
221
- });
222
- return { pngPath, htmlPath, condensedHtmlPath, baseName: snapshotRunId };
223
- } catch (err) {
224
- let pageAlive = false;
225
- let browserConnected = false;
226
- try {
227
- browserConnected = browser.isConnected();
228
- pageAlive = !page.isClosed();
229
- } catch {}
230
- logger.error("screenshot-error", {
231
- error: err,
232
- session,
233
- pageAlive,
234
- browserConnected,
235
- pageUrl: (() => {
236
- try {
237
- return page.url();
238
- } catch {
239
- return null;
240
- }
241
- })(),
242
- });
243
- throw err;
244
- } finally {
245
- disconnectBrowser(browser, logger, session);
246
- }
144
+ return { pngPath, htmlPath, condensedHtmlPath, baseName: snapshotRunId };
247
145
  }
248
146
 
249
147
  async function runSnapshot(
@@ -259,11 +157,16 @@ async function runSnapshot(
259
157
  const snapshotModel = readSnapshotModel();
260
158
  resolveSnapshotApiModelOrThrow(snapshotModel);
261
159
 
262
- const { pngPath, htmlPath, condensedHtmlPath } = await captureScreenshot(
263
- session,
264
- logger,
265
- pageId,
266
- );
160
+ const state = readSessionState(session, logger);
161
+ if (!state?.daemonSocketPath) {
162
+ throw new Error(
163
+ `Session "${session}" has no daemon socket. The browser daemon may have crashed. ` +
164
+ `Close and reopen the session: libretto close --session ${session}`,
165
+ );
166
+ }
167
+
168
+ const { pngPath, htmlPath, condensedHtmlPath } =
169
+ await captureSnapshot(session, logger, state.daemonSocketPath, pageId);
267
170
 
268
171
  console.log("Screenshot saved:");
269
172
  console.log(` PNG: ${pngPath}`);
@@ -5,7 +5,6 @@ import {
5
5
  parseModel,
6
6
  type Provider,
7
7
  } from "./resolve-model.js";
8
- import { loadEnv } from "../../shared/env/load-env.js";
9
8
 
10
9
  // Re-export so existing consumers (e.g. tests) don't break.
11
10
  export { parseDotEnvAssignment } from "../../shared/env/load-env.js";
@@ -148,6 +147,10 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
148
147
  /**
149
148
  * Resolve which API model to use for snapshot analysis.
150
149
  *
150
+ * Environment variables are loaded by the CLI entrypoint (`runLibrettoCLI` in
151
+ * `cli.ts`) before this resolver runs. Keep dotenv loading centralized there so
152
+ * model resolution and browser provider resolution share the same env setup.
153
+ *
151
154
  * Priority:
152
155
  * 1. snapshotModel from .libretto/config.json (set via `ai configure`)
153
156
  * 2. Auto-detect from available API credentials in env
@@ -155,8 +158,6 @@ function inferAutoSnapshotModel(): SnapshotApiModelSelection | null {
155
158
  export function resolveSnapshotApiModel(
156
159
  snapshotModel: string | null = readSnapshotModel(),
157
160
  ): SnapshotApiModelSelection | null {
158
- loadEnv();
159
-
160
161
  if (snapshotModel) {
161
162
  const { provider } = parseModel(snapshotModel);
162
163
  return {
@@ -246,8 +247,6 @@ function readSnapshotModelSafely(
246
247
  export function resolveAiSetupStatus(
247
248
  configPath: string = LIBRETTO_CONFIG_PATH,
248
249
  ): AiSetupStatus {
249
- loadEnv();
250
-
251
250
  const result = readSnapshotModelSafely(configPath);
252
251
 
253
252
  if (!result.ok) {
@@ -0,0 +1,283 @@
1
+ /**
2
+ * HTTP helpers used by the auth CLI commands.
3
+ *
4
+ * - `authFetch` picks the best available credential (env api-key > stored
5
+ * api-key > stored cookie) and attaches it to the outgoing request.
6
+ * - `orpcCall` wraps the JSON shape used by the api's RPCHandler at /v1/*
7
+ * (input is `{ json: ... }`, output unwraps `body.json`).
8
+ *
9
+ * The helpers don't know about specific endpoints — callers pass paths.
10
+ */
11
+
12
+ import { readAuthState, writeAuthState, type AuthState } from "./auth-storage.js";
13
+
14
+ export const HOSTED_API_URL = "https://api.libretto.sh";
15
+
16
+ /**
17
+ * Shared "you have no usable credential" message. Pointed at the two
18
+ * recovery paths so users don't have to remember which mechanism does what.
19
+ */
20
+ export const NOT_AUTHENTICATED_MESSAGE = [
21
+ "Not authenticated.",
22
+ " • Cookie expired or never set: run `libretto experimental auth login` to refresh it.",
23
+ " • Or set LIBRETTO_API_KEY in your .env (issue one with `libretto experimental auth api-key issue --label <label>` after logging in).",
24
+ ].join("\n");
25
+
26
+ export type CredentialSource = "env-api-key" | "cookie" | "none";
27
+
28
+ export type CredentialChoice = {
29
+ source: CredentialSource;
30
+ apiKey?: string;
31
+ cookie?: string;
32
+ };
33
+
34
+ export function pickCredential(state: AuthState | null): CredentialChoice {
35
+ const envKey = process.env.LIBRETTO_API_KEY?.trim();
36
+ if (envKey) return { source: "env-api-key", apiKey: envKey };
37
+ if (state?.session?.cookie) {
38
+ return { source: "cookie", cookie: state.session.cookie };
39
+ }
40
+ return { source: "none" };
41
+ }
42
+
43
+ export function resolveApiUrl(_state: AuthState | null): string {
44
+ return HOSTED_API_URL;
45
+ }
46
+
47
+ type FetchOptions = {
48
+ apiUrl: string;
49
+ method?: "GET" | "POST" | "PUT" | "DELETE";
50
+ path: string;
51
+ body?: unknown;
52
+ /** Override the credential picked from auth state. */
53
+ credential?: CredentialChoice;
54
+ /** Skip credential injection entirely (used for sign-up / login). */
55
+ unauthenticated?: boolean;
56
+ };
57
+
58
+ export async function authFetch(options: FetchOptions): Promise<Response> {
59
+ const headers: Record<string, string> = {
60
+ "Content-Type": "application/json",
61
+ // Better Auth's CSRF middleware rejects state-changing requests
62
+ // ("/api/auth/*" POSTs like api-key/create, organization/invite-member,
63
+ // sign-in/email, etc.) when there's no Origin header. Browsers send
64
+ // this automatically; node:fetch does not. Sending the apiUrl as the
65
+ // Origin matches Better Auth's trustedOrigins default (which includes
66
+ // baseURL), so the check passes for our own service.
67
+ Origin: options.apiUrl,
68
+ };
69
+
70
+ if (!options.unauthenticated) {
71
+ const credential = options.credential ?? pickCredential(await readAuthState());
72
+ if (credential.source === "env-api-key") {
73
+ headers["x-api-key"] = credential.apiKey!;
74
+ } else if (credential.source === "cookie") {
75
+ headers["cookie"] = credential.cookie!;
76
+ } else {
77
+ throw new Error(NOT_AUTHENTICATED_MESSAGE);
78
+ }
79
+ }
80
+
81
+ const response = await fetch(`${options.apiUrl}${options.path}`, {
82
+ method: options.method ?? "POST",
83
+ headers,
84
+ body: options.body !== undefined ? JSON.stringify(options.body) : undefined,
85
+ });
86
+
87
+ return response;
88
+ }
89
+
90
+ /**
91
+ * Error thrown by `orpcCall` / `betterAuthCall` for non-2xx responses.
92
+ *
93
+ * Carries the HTTP status, the ORPC-serialized error code (e.g. "CONFLICT",
94
+ * "BAD_REQUEST"), and the optional `data` payload that the server attaches
95
+ * (e.g. `{ reason: "slug_taken" }`). Callers can branch on these without
96
+ * relying on message-text matching.
97
+ */
98
+ export class ApiCallError extends Error {
99
+ readonly status: number;
100
+ readonly code: string | null;
101
+ readonly data: unknown;
102
+ readonly path: string;
103
+ constructor(opts: {
104
+ message: string;
105
+ status: number;
106
+ code: string | null;
107
+ data: unknown;
108
+ path: string;
109
+ }) {
110
+ super(opts.message);
111
+ this.name = "ApiCallError";
112
+ this.status = opts.status;
113
+ this.code = opts.code;
114
+ this.data = opts.data;
115
+ this.path = opts.path;
116
+ }
117
+ }
118
+
119
+ export async function orpcCall<TResult>(opts: {
120
+ apiUrl: string;
121
+ path: string;
122
+ input?: Record<string, unknown>;
123
+ unauthenticated?: boolean;
124
+ credential?: CredentialChoice;
125
+ }): Promise<TResult> {
126
+ const response = await authFetch({
127
+ apiUrl: opts.apiUrl,
128
+ method: "POST",
129
+ path: opts.path,
130
+ body: { json: opts.input ?? {} },
131
+ unauthenticated: opts.unauthenticated,
132
+ credential: opts.credential,
133
+ });
134
+
135
+ const text = await response.text();
136
+ let parsed: unknown;
137
+ try {
138
+ parsed = text.length === 0 ? {} : JSON.parse(text);
139
+ } catch {
140
+ throw new ApiCallError({
141
+ message: `Unexpected non-JSON response from ${opts.path} (${response.status}): ${text.slice(0, 200)}`,
142
+ status: response.status,
143
+ code: null,
144
+ data: null,
145
+ path: opts.path,
146
+ });
147
+ }
148
+
149
+ if (!response.ok) {
150
+ const message = extractErrorMessage(parsed) ?? `${opts.path} failed (${response.status})`;
151
+ throw new ApiCallError({
152
+ message,
153
+ status: response.status,
154
+ code: extractErrorCode(parsed),
155
+ data: extractErrorData(parsed),
156
+ path: opts.path,
157
+ });
158
+ }
159
+
160
+ const json = (parsed as { json?: unknown }).json;
161
+ if (json === undefined) {
162
+ return parsed as TResult;
163
+ }
164
+ return json as TResult;
165
+ }
166
+
167
+ /**
168
+ * Better Auth endpoints at /api/auth/* return plain JSON (not ORPC-wrapped).
169
+ * They also set `Set-Cookie` on sign-in. This helper exposes both.
170
+ */
171
+ export async function betterAuthCall<TResult>(opts: {
172
+ apiUrl: string;
173
+ path: string;
174
+ method?: "GET" | "POST";
175
+ input?: unknown;
176
+ unauthenticated?: boolean;
177
+ credential?: CredentialChoice;
178
+ }): Promise<{ data: TResult; setCookie: string[] }> {
179
+ const response = await authFetch({
180
+ apiUrl: opts.apiUrl,
181
+ method: opts.method ?? "POST",
182
+ path: opts.path,
183
+ body: opts.input,
184
+ unauthenticated: opts.unauthenticated,
185
+ credential: opts.credential,
186
+ });
187
+
188
+ const text = await response.text();
189
+ let parsed: unknown;
190
+ try {
191
+ parsed = text.length === 0 ? {} : JSON.parse(text);
192
+ } catch {
193
+ throw new Error(
194
+ `Unexpected non-JSON response from ${opts.path} (${response.status}): ${text.slice(0, 200)}`,
195
+ );
196
+ }
197
+
198
+ if (!response.ok) {
199
+ const message = extractErrorMessage(parsed) ?? `${opts.path} failed (${response.status})`;
200
+ throw new ApiCallError({
201
+ message,
202
+ status: response.status,
203
+ code: extractErrorCode(parsed),
204
+ data: extractErrorData(parsed),
205
+ path: opts.path,
206
+ });
207
+ }
208
+
209
+ const setCookie = readSetCookies(response);
210
+ return { data: parsed as TResult, setCookie };
211
+ }
212
+
213
+ function readSetCookies(response: Response): string[] {
214
+ const headers = response.headers as Headers & {
215
+ getSetCookie?: () => string[];
216
+ };
217
+ if (typeof headers.getSetCookie === "function") {
218
+ return headers.getSetCookie();
219
+ }
220
+ const single = response.headers.get("set-cookie");
221
+ return single ? [single] : [];
222
+ }
223
+
224
+ function extractErrorMessage(body: unknown): string | null {
225
+ if (!body || typeof body !== "object") return null;
226
+ const record = body as Record<string, unknown>;
227
+ if (typeof record.message === "string") return record.message;
228
+ if (
229
+ record.json &&
230
+ typeof record.json === "object" &&
231
+ typeof (record.json as Record<string, unknown>).message === "string"
232
+ ) {
233
+ return (record.json as Record<string, string>).message;
234
+ }
235
+ if (record.error && typeof record.error === "object") {
236
+ const errMsg = (record.error as Record<string, unknown>).message;
237
+ if (typeof errMsg === "string") return errMsg;
238
+ }
239
+ return null;
240
+ }
241
+
242
+ function extractErrorCode(body: unknown): string | null {
243
+ if (!body || typeof body !== "object") return null;
244
+ const record = body as Record<string, unknown>;
245
+ if (typeof record.code === "string") return record.code;
246
+ if (
247
+ record.json &&
248
+ typeof record.json === "object" &&
249
+ typeof (record.json as Record<string, unknown>).code === "string"
250
+ ) {
251
+ return (record.json as Record<string, string>).code;
252
+ }
253
+ if (record.error && typeof record.error === "object") {
254
+ const code = (record.error as Record<string, unknown>).code;
255
+ if (typeof code === "string") return code;
256
+ }
257
+ return null;
258
+ }
259
+
260
+ function extractErrorData(body: unknown): unknown {
261
+ if (!body || typeof body !== "object") return null;
262
+ const record = body as Record<string, unknown>;
263
+ if (record.data !== undefined) return record.data;
264
+ if (record.json && typeof record.json === "object") {
265
+ const inner = (record.json as Record<string, unknown>).data;
266
+ if (inner !== undefined) return inner;
267
+ }
268
+ if (record.error && typeof record.error === "object") {
269
+ const inner = (record.error as Record<string, unknown>).data;
270
+ if (inner !== undefined) return inner;
271
+ }
272
+ return null;
273
+ }
274
+
275
+ export async function ensureAuthState(apiUrl: string): Promise<AuthState> {
276
+ const existing = await readAuthState();
277
+ if (existing && existing.apiUrl === apiUrl) return existing;
278
+ const next: AuthState = existing
279
+ ? { ...existing, apiUrl }
280
+ : { apiUrl, session: null };
281
+ await writeAuthState(next);
282
+ return next;
283
+ }
@@ -0,0 +1,102 @@
1
+ /**
2
+ * Read/write the libretto CLI auth state at ~/.libretto/auth.json.
3
+ *
4
+ * Stores only the interactive CLI session — the cookie returned by sign-up
5
+ * or login. API keys are never persisted here; users put `LIBRETTO_API_KEY`
6
+ * in their `.env` (matching the existing convention used for
7
+ * BROWSERBASE_API_KEY / KERNEL_API_KEY).
8
+ *
9
+ * Notably, the active org id is NOT cached here. The server is the source
10
+ * of truth (api-key metadata.tenantId is server-overridden, and CLI
11
+ * commands that need the org id resolve it via /organization/list).
12
+ *
13
+ * Lookup order for credentials when making API requests:
14
+ * 1. process.env.LIBRETTO_API_KEY (explicit override; CI-friendly)
15
+ * 2. authState.session.cookie (from sign-up or login response)
16
+ *
17
+ * File is mode 0600 — only the current user can read it.
18
+ */
19
+
20
+ import { promises as fs } from "node:fs";
21
+ import { homedir } from "node:os";
22
+ import { join } from "node:path";
23
+
24
+ export type StoredSession = {
25
+ /**
26
+ * The full Cookie request-header value to replay on subsequent
27
+ * /api/auth/* calls. Built from the Set-Cookie values returned by sign-up /
28
+ * login by stripping attributes (Path, HttpOnly, etc) and joining the
29
+ * `name=value` pairs with "; ".
30
+ */
31
+ cookie: string;
32
+ userId: string;
33
+ email: string;
34
+ /** ISO-8601 expiry of the underlying session row, if known. */
35
+ expiresAt: string | null;
36
+ };
37
+
38
+ export type AuthState = {
39
+ /** The hosted-platform base URL the credentials are valid for. */
40
+ apiUrl: string;
41
+ session: StoredSession | null;
42
+ };
43
+
44
+ const FILE_NAME = "auth.json";
45
+
46
+ function authDir(): string {
47
+ return join(homedir(), ".libretto");
48
+ }
49
+
50
+ function authPath(): string {
51
+ return join(authDir(), FILE_NAME);
52
+ }
53
+
54
+ export async function readAuthState(): Promise<AuthState | null> {
55
+ try {
56
+ const raw = await fs.readFile(authPath(), "utf8");
57
+ const parsed = JSON.parse(raw) as Partial<AuthState>;
58
+ if (!parsed.apiUrl || typeof parsed.apiUrl !== "string") return null;
59
+ return {
60
+ apiUrl: parsed.apiUrl,
61
+ session: parsed.session ?? null,
62
+ };
63
+ } catch (error) {
64
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
65
+ throw error;
66
+ }
67
+ }
68
+
69
+ export async function writeAuthState(state: AuthState): Promise<void> {
70
+ await fs.mkdir(authDir(), { recursive: true, mode: 0o700 });
71
+ const payload = JSON.stringify(state, null, 2);
72
+ // Write through a temp file + rename so a partial write can never corrupt
73
+ // the credentials file.
74
+ const target = authPath();
75
+ const tmp = `${target}.tmp`;
76
+ await fs.writeFile(tmp, payload, { mode: 0o600 });
77
+ await fs.rename(tmp, target);
78
+ }
79
+
80
+ export async function clearAuthState(): Promise<void> {
81
+ try {
82
+ await fs.unlink(authPath());
83
+ } catch (error) {
84
+ if ((error as NodeJS.ErrnoException).code !== "ENOENT") throw error;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * Convert the array of Set-Cookie headers returned by Better Auth into a
90
+ * single `Cookie:` request-header value. Drops attributes like Path,
91
+ * HttpOnly, Max-Age — only the `name=value` pair survives.
92
+ */
93
+ export function setCookieToCookieHeader(setCookie: readonly string[]): string {
94
+ return setCookie
95
+ .map((entry) => entry.split(";")[0]?.trim())
96
+ .filter((pair): pair is string => Boolean(pair && pair.includes("=")))
97
+ .join("; ");
98
+ }
99
+
100
+ export function authStatePath(): string {
101
+ return authPath();
102
+ }