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.
- package/dist/cli/cli.js +2 -0
- package/dist/cli/commands/auth.js +535 -0
- package/dist/cli/commands/billing.js +74 -0
- package/dist/cli/commands/browser.js +8 -3
- package/dist/cli/commands/deploy.js +2 -7
- package/dist/cli/commands/execution.js +99 -136
- package/dist/cli/commands/snapshot.js +38 -126
- package/dist/cli/core/ai-model.js +0 -3
- package/dist/cli/core/auth-fetch.js +195 -0
- package/dist/cli/core/auth-storage.js +52 -0
- package/dist/cli/core/browser.js +128 -202
- package/dist/cli/core/daemon/config.js +6 -0
- package/dist/cli/core/daemon/daemon.js +298 -0
- package/dist/cli/core/daemon/exec.js +86 -0
- package/dist/cli/core/daemon/index.js +16 -0
- package/dist/cli/core/daemon/ipc.js +171 -0
- package/dist/cli/core/daemon/pages.js +15 -0
- package/dist/cli/core/daemon/snapshot.js +86 -0
- package/dist/cli/core/daemon/spawn.js +90 -0
- package/dist/cli/core/exec-compiler.js +111 -0
- package/dist/cli/core/prompt.js +72 -0
- package/dist/cli/core/providers/libretto-cloud.js +2 -6
- package/dist/cli/core/readonly-exec.js +1 -1
- package/dist/cli/router.js +4 -0
- package/dist/cli/workers/run-integration-runtime.js +0 -5
- package/dist/shared/state/session-state.d.ts +1 -0
- package/dist/shared/state/session-state.js +2 -1
- package/docs/browser-automation-approaches.md +435 -0
- package/docs/releasing.md +117 -0
- package/package.json +4 -3
- package/skills/libretto/SKILL.md +14 -1
- package/skills/libretto-readonly/SKILL.md +1 -1
- package/src/cli/cli.ts +2 -0
- package/src/cli/commands/auth.ts +787 -0
- package/src/cli/commands/billing.ts +133 -0
- package/src/cli/commands/browser.ts +8 -2
- package/src/cli/commands/deploy.ts +2 -7
- package/src/cli/commands/execution.ts +126 -186
- package/src/cli/commands/snapshot.ts +46 -143
- package/src/cli/core/ai-model.ts +4 -5
- package/src/cli/core/auth-fetch.ts +283 -0
- package/src/cli/core/auth-storage.ts +102 -0
- package/src/cli/core/browser.ts +159 -242
- package/src/cli/core/daemon/config.ts +46 -0
- package/src/cli/core/daemon/daemon.ts +429 -0
- package/src/cli/core/daemon/exec.ts +128 -0
- package/src/cli/core/daemon/index.ts +24 -0
- package/src/cli/core/daemon/ipc.ts +294 -0
- package/src/cli/core/daemon/pages.ts +21 -0
- package/src/cli/core/daemon/snapshot.ts +114 -0
- package/src/cli/core/daemon/spawn.ts +171 -0
- package/src/cli/core/exec-compiler.ts +169 -0
- package/src/cli/core/prompt.ts +94 -0
- package/src/cli/core/providers/libretto-cloud.ts +2 -6
- package/src/cli/core/readonly-exec.ts +2 -1
- package/src/cli/router.ts +4 -0
- package/src/cli/workers/run-integration-runtime.ts +0 -6
- package/src/shared/state/session-state.ts +1 -0
- package/dist/cli/core/browser-daemon.js +0 -122
- package/src/cli/core/browser-daemon.ts +0 -198
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
import {
|
|
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
|
|
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("
|
|
123
|
-
const
|
|
124
|
-
const
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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}`);
|
package/src/cli/core/ai-model.ts
CHANGED
|
@@ -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
|
+
}
|