libretto 0.6.13 → 0.6.15

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 (57) hide show
  1. package/dist/cli/commands/auth.js +43 -33
  2. package/dist/cli/commands/billing.js +3 -5
  3. package/dist/cli/commands/browser.js +3 -6
  4. package/dist/cli/commands/deploy.js +54 -45
  5. package/dist/cli/commands/execution.js +7 -4
  6. package/dist/cli/commands/experiments.js +1 -1
  7. package/dist/cli/commands/setup.js +1 -1
  8. package/dist/cli/commands/shared.js +1 -1
  9. package/dist/cli/commands/snapshot.js +1 -1
  10. package/dist/cli/commands/status.js +1 -1
  11. package/dist/cli/core/auth-fetch.js +11 -6
  12. package/dist/cli/core/browser.js +10 -5
  13. package/dist/cli/core/daemon/daemon.js +63 -10
  14. package/dist/cli/core/daemon/exec-repl.js +133 -0
  15. package/dist/cli/core/daemon/exec.js +6 -21
  16. package/dist/cli/core/daemon/ipc.js +47 -4
  17. package/dist/cli/core/daemon/ipc.spec.js +21 -0
  18. package/dist/cli/core/exec-compiler.js +8 -3
  19. package/dist/cli/core/providers/index.js +13 -4
  20. package/dist/cli/core/providers/kernel.js +3 -3
  21. package/dist/cli/core/providers/libretto-cloud.js +178 -26
  22. package/dist/cli/router.js +9 -4
  23. package/dist/shared/ipc/socket-transport.d.ts +2 -1
  24. package/dist/shared/ipc/socket-transport.js +16 -5
  25. package/dist/shared/ipc/socket-transport.spec.js +5 -0
  26. package/package.json +2 -2
  27. package/skills/libretto/SKILL.md +33 -29
  28. package/skills/libretto/references/code-generation-rules.md +6 -0
  29. package/skills/libretto/references/configuration-file-reference.md +8 -0
  30. package/skills/libretto/references/site-security-review.md +6 -6
  31. package/skills/libretto-readonly/SKILL.md +1 -1
  32. package/src/cli/commands/auth.ts +46 -33
  33. package/src/cli/commands/billing.ts +3 -5
  34. package/src/cli/commands/browser.ts +5 -9
  35. package/src/cli/commands/deploy.ts +55 -49
  36. package/src/cli/commands/execution.ts +7 -4
  37. package/src/cli/commands/experiments.ts +1 -1
  38. package/src/cli/commands/setup.ts +1 -1
  39. package/src/cli/commands/shared.ts +1 -1
  40. package/src/cli/commands/snapshot.ts +1 -1
  41. package/src/cli/commands/status.ts +1 -1
  42. package/src/cli/core/auth-fetch.ts +9 -4
  43. package/src/cli/core/browser.ts +12 -5
  44. package/src/cli/core/daemon/daemon.ts +81 -9
  45. package/src/cli/core/daemon/exec-repl.ts +189 -0
  46. package/src/cli/core/daemon/exec.ts +8 -43
  47. package/src/cli/core/daemon/ipc.spec.ts +27 -0
  48. package/src/cli/core/daemon/ipc.ts +76 -7
  49. package/src/cli/core/exec-compiler.ts +8 -3
  50. package/src/cli/core/providers/index.ts +17 -4
  51. package/src/cli/core/providers/kernel.ts +4 -3
  52. package/src/cli/core/providers/libretto-cloud.ts +224 -36
  53. package/src/cli/router.ts +9 -4
  54. package/src/shared/ipc/socket-transport.spec.ts +6 -0
  55. package/src/shared/ipc/socket-transport.ts +20 -5
  56. package/dist/cli/framework/simple-cli.js +0 -880
  57. package/src/cli/framework/simple-cli.ts +0 -1459
@@ -1,14 +1,15 @@
1
1
  import type { ProviderApi } from "./types.js";
2
2
 
3
+ const KERNEL_API_ENDPOINT = "https://api.onkernel.com";
4
+
3
5
  export function createKernelProvider(): ProviderApi {
4
6
  const apiKey = process.env.KERNEL_API_KEY;
5
7
  if (!apiKey)
6
8
  throw new Error("KERNEL_API_KEY is required for Kernel provider.");
7
- const endpoint = process.env.KERNEL_ENDPOINT ?? "https://api.onkernel.com";
8
9
 
9
10
  return {
10
11
  async createSession() {
11
- const resp = await fetch(`${endpoint}/browsers`, {
12
+ const resp = await fetch(`${KERNEL_API_ENDPOINT}/browsers`, {
12
13
  method: "POST",
13
14
  headers: {
14
15
  Authorization: `Bearer ${apiKey}`,
@@ -34,7 +35,7 @@ export function createKernelProvider(): ProviderApi {
34
35
  };
35
36
  },
36
37
  async closeSession(sessionId) {
37
- const resp = await fetch(`${endpoint}/browsers/${sessionId}`, {
38
+ const resp = await fetch(`${KERNEL_API_ENDPOINT}/browsers/${sessionId}`, {
38
39
  method: "DELETE",
39
40
  headers: { Authorization: `Bearer ${apiKey}` },
40
41
  });
@@ -1,20 +1,33 @@
1
- import { HOSTED_API_URL } from "../auth-fetch.js";
1
+ import { resolveHostedApiUrl } from "../auth-fetch.js";
2
2
  import type { ProviderApi } from "./types.js";
3
3
 
4
+ type CloudSessionResponse = {
5
+ session_id: string;
6
+ status: string;
7
+ cdp_url: string | null;
8
+ live_view_url: string | null;
9
+ recording_url: string | null;
10
+ };
11
+
12
+ const DEFAULT_POLL_INTERVAL_MS = 2_000;
13
+ const DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS = 3_600;
14
+ const QUEUE_WAIT_TIMEOUT_MS = 10 * 60_000;
15
+
4
16
  export function createLibrettoCloudProvider(): ProviderApi {
5
17
  const apiKey = process.env.LIBRETTO_API_KEY;
6
18
  if (!apiKey)
7
19
  throw new Error(
8
20
  "LIBRETTO_API_KEY is required for the Libretto Cloud provider.",
9
21
  );
10
- const endpoint = HOSTED_API_URL;
22
+ const endpoint = resolveHostedApiUrl();
11
23
 
12
24
  // The Libretto Cloud API is an oRPC RPCHandler, not plain REST, so inputs
13
25
  // must be wrapped as { json: ... } and outputs arrive the same way.
14
26
  return {
15
27
  async createSession() {
16
- const timeoutSeconds = Number(
17
- process.env.LIBRETTO_TIMEOUT_SECONDS ?? 7200,
28
+ const browserSessionTimeoutSeconds = readPositiveNumberEnv(
29
+ "LIBRETTO_TIMEOUT_SECONDS",
30
+ DEFAULT_BROWSER_SESSION_TIMEOUT_SECONDS,
18
31
  );
19
32
  const resp = await fetch(`${endpoint}/v1/sessions/create`, {
20
33
  method: "POST",
@@ -23,48 +36,223 @@ export function createLibrettoCloudProvider(): ProviderApi {
23
36
  "Content-Type": "application/json",
24
37
  },
25
38
  body: JSON.stringify({
26
- json: { timeout_seconds: timeoutSeconds },
39
+ json: { timeout_seconds: browserSessionTimeoutSeconds },
27
40
  }),
28
41
  });
29
42
  if (!resp.ok) {
30
43
  const body = await resp.text();
31
- throw new Error(
32
- `Libretto Cloud API error (${resp.status}): ${body}`,
33
- );
44
+ throw new Error(`Libretto Cloud API error (${resp.status}): ${body}`);
45
+ }
46
+ const { json } = (await resp.json()) as { json: CloudSessionResponse };
47
+ const startupCleanup = createStartupSessionCleanup(
48
+ endpoint,
49
+ apiKey,
50
+ json.session_id,
51
+ );
52
+ let readySession: CloudSessionResponse & { cdp_url: string };
53
+ try {
54
+ readySession = await waitForCloudSessionReady({
55
+ endpoint,
56
+ apiKey,
57
+ session: json,
58
+ timeoutMs: QUEUE_WAIT_TIMEOUT_MS,
59
+ isCancelled: startupCleanup.isCancelled,
60
+ });
61
+ } catch (error) {
62
+ if (startupCleanup.isCancelled()) {
63
+ await startupCleanup.waitForClose();
64
+ } else {
65
+ await closeCloudSession(endpoint, apiKey, json.session_id).catch(
66
+ () => {},
67
+ );
68
+ }
69
+ throw error;
70
+ } finally {
71
+ startupCleanup.dispose();
34
72
  }
35
- const { json } = (await resp.json()) as {
36
- json: {
37
- session_id: string;
38
- cdp_url: string;
39
- live_view_url: string | null;
40
- recording_url: string | null;
41
- };
42
- };
43
73
  return {
44
- sessionId: json.session_id,
45
- cdpEndpoint: json.cdp_url,
46
- liveViewUrl: json.live_view_url ?? undefined,
74
+ sessionId: readySession.session_id,
75
+ cdpEndpoint: readySession.cdp_url,
76
+ liveViewUrl: readySession.live_view_url ?? undefined,
47
77
  };
48
78
  },
49
79
  async closeSession(sessionId) {
50
- const resp = await fetch(`${endpoint}/v1/sessions/close`, {
51
- method: "POST",
52
- headers: {
53
- "x-api-key": apiKey,
54
- "Content-Type": "application/json",
55
- },
56
- body: JSON.stringify({ json: { session_id: sessionId } }),
57
- });
58
- if (!resp.ok) {
59
- const body = await resp.text();
60
- throw new Error(
61
- `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
62
- );
63
- }
64
- const { json } = (await resp.json()) as {
65
- json: { replay_url: string | null };
66
- };
80
+ const json = await closeCloudSession(endpoint, apiKey, sessionId);
67
81
  return { replayUrl: json.replay_url ?? undefined };
68
82
  },
69
83
  };
70
84
  }
85
+
86
+ async function waitForCloudSessionReady(args: {
87
+ endpoint: string;
88
+ apiKey: string;
89
+ session: CloudSessionResponse;
90
+ timeoutMs: number;
91
+ isCancelled?: () => boolean;
92
+ }): Promise<CloudSessionResponse & { cdp_url: string }> {
93
+ let session = args.session;
94
+ if (args.isCancelled?.()) {
95
+ throw new Error(
96
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
97
+ );
98
+ }
99
+ if (session.cdp_url) {
100
+ return { ...session, cdp_url: session.cdp_url };
101
+ }
102
+
103
+ sendStartupStatus(
104
+ `Libretto Cloud browser session queued (session: ${session.session_id}). Waiting for browser capacity...`,
105
+ );
106
+
107
+ const pollIntervalMs = readPositiveNumberEnv(
108
+ "LIBRETTO_CLOUD_SESSION_POLL_INTERVAL_MS",
109
+ DEFAULT_POLL_INTERVAL_MS,
110
+ );
111
+ const deadline = Date.now() + args.timeoutMs;
112
+
113
+ while (Date.now() < deadline) {
114
+ if (args.isCancelled?.()) {
115
+ throw new Error(
116
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
117
+ );
118
+ }
119
+ await sleep(pollIntervalMs);
120
+ if (args.isCancelled?.()) {
121
+ throw new Error(
122
+ `Libretto Cloud session ${session.session_id} was cancelled before browser capacity was available.`,
123
+ );
124
+ }
125
+ session = await getCloudSession(
126
+ args.endpoint,
127
+ args.apiKey,
128
+ session.session_id,
129
+ );
130
+ if (session.cdp_url) {
131
+ sendStartupStatus(
132
+ `Libretto Cloud browser capacity available (session: ${session.session_id}). Connecting...`,
133
+ );
134
+ return { ...session, cdp_url: session.cdp_url };
135
+ }
136
+ if (!["queued", "starting"].includes(session.status)) {
137
+ throw new Error(
138
+ `Libretto Cloud session ${session.session_id} entered status "${session.status}" before a CDP URL was available.`,
139
+ );
140
+ }
141
+ }
142
+
143
+ throw new Error(
144
+ `Timed out waiting for Libretto Cloud browser capacity after ${Math.ceil(args.timeoutMs / 1_000)}s (session: ${session.session_id}).`,
145
+ );
146
+ }
147
+
148
+ async function getCloudSession(
149
+ endpoint: string,
150
+ apiKey: string,
151
+ sessionId: string,
152
+ ): Promise<CloudSessionResponse> {
153
+ const resp = await fetch(`${endpoint}/v1/sessions/get`, {
154
+ method: "POST",
155
+ headers: {
156
+ "x-api-key": apiKey,
157
+ "Content-Type": "application/json",
158
+ },
159
+ body: JSON.stringify({ json: { session_id: sessionId } }),
160
+ });
161
+ if (!resp.ok) {
162
+ const body = await resp.text();
163
+ throw new Error(
164
+ `Libretto Cloud API error reading session ${sessionId} (${resp.status}): ${body}`,
165
+ );
166
+ }
167
+ const { json } = (await resp.json()) as { json: CloudSessionResponse };
168
+ return json;
169
+ }
170
+
171
+ async function closeCloudSession(
172
+ endpoint: string,
173
+ apiKey: string,
174
+ sessionId: string,
175
+ ): Promise<{ replay_url: string | null }> {
176
+ const resp = await fetch(`${endpoint}/v1/sessions/close`, {
177
+ method: "POST",
178
+ headers: {
179
+ "x-api-key": apiKey,
180
+ "Content-Type": "application/json",
181
+ },
182
+ body: JSON.stringify({ json: { session_id: sessionId } }),
183
+ });
184
+ if (!resp.ok) {
185
+ const body = await resp.text();
186
+ throw new Error(
187
+ `Libretto Cloud API error closing session ${sessionId} (${resp.status}): ${body}`,
188
+ );
189
+ }
190
+ const { json } = (await resp.json()) as {
191
+ json: { replay_url: string | null };
192
+ };
193
+ return json;
194
+ }
195
+
196
+ function createStartupSessionCleanup(
197
+ endpoint: string,
198
+ apiKey: string,
199
+ sessionId: string,
200
+ ): {
201
+ isCancelled: () => boolean;
202
+ waitForClose: () => Promise<void>;
203
+ dispose: () => void;
204
+ } {
205
+ let cancelled = false;
206
+ let closePromise: Promise<void> | null = null;
207
+
208
+ const requestClose = (reason: string): void => {
209
+ if (cancelled) return;
210
+ cancelled = true;
211
+ sendStartupStatus(
212
+ `Libretto Cloud browser session cancelled (${reason}). Cleaning up queued session...`,
213
+ );
214
+ closePromise = closeCloudSession(endpoint, apiKey, sessionId).then(
215
+ () => {},
216
+ () => {},
217
+ );
218
+ };
219
+
220
+ const onDisconnect = (): void => requestClose("parent command disconnected");
221
+ const onSigint = (): void => requestClose("received SIGINT");
222
+ const onSigterm = (): void => requestClose("received SIGTERM");
223
+
224
+ if (typeof process.send === "function") {
225
+ process.once("disconnect", onDisconnect);
226
+ }
227
+ process.once("SIGINT", onSigint);
228
+ process.once("SIGTERM", onSigterm);
229
+
230
+ return {
231
+ isCancelled: () => cancelled,
232
+ waitForClose: async () => {
233
+ await closePromise;
234
+ },
235
+ dispose: () => {
236
+ process.off("disconnect", onDisconnect);
237
+ process.off("SIGINT", onSigint);
238
+ process.off("SIGTERM", onSigterm);
239
+ },
240
+ };
241
+ }
242
+
243
+ function readPositiveNumberEnv(name: string, fallback: number): number {
244
+ const raw = process.env[name];
245
+ if (!raw) return fallback;
246
+ const parsed = Number(raw);
247
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback;
248
+ }
249
+
250
+ function sendStartupStatus(message: string): void {
251
+ if (typeof process.send === "function") {
252
+ process.send({ type: "startup-status", message });
253
+ }
254
+ }
255
+
256
+ function sleep(ms: number): Promise<void> {
257
+ return new Promise((resolve) => setTimeout(resolve, ms));
258
+ }
package/src/cli/router.ts CHANGED
@@ -8,15 +8,20 @@ import { setupCommand } from "./commands/setup.js";
8
8
  import { statusCommand } from "./commands/status.js";
9
9
  import { snapshotCommand } from "./commands/snapshot.js";
10
10
  import { librettoCommand } from "../shared/package-manager.js";
11
- import { SimpleCLI } from "./framework/simple-cli.js";
11
+ import { SimpleCLI } from "affordance";
12
12
 
13
13
  export const cliRoutes = {
14
14
  ...browserCommands,
15
- deploy: deployCommand,
15
+ cloud: SimpleCLI.group({
16
+ description: "Libretto Cloud commands",
17
+ routes: {
18
+ deploy: deployCommand,
19
+ auth: authCommands,
20
+ billing: billingCommands,
21
+ },
22
+ }),
16
23
  experiments: experimentsCommand,
17
24
  ...executionCommands,
18
- auth: authCommands,
19
- billing: billingCommands,
20
25
  setup: setupCommand,
21
26
  status: statusCommand,
22
27
  snapshot: snapshotCommand,
@@ -6,6 +6,7 @@ import { expect, test as base } from "vitest";
6
6
  import { createIpcPeer, type IpcPeer } from "./ipc.js";
7
7
  import {
8
8
  connectToIpcSocket,
9
+ isWindowsNamedPipePath,
9
10
  listenForIpcConnections,
10
11
  } from "./socket-transport.js";
11
12
 
@@ -64,6 +65,11 @@ test("sends concurrent calls over one socket", async ({ socketPath }) => {
64
65
  await expect(stat(socketPath)).rejects.toThrow();
65
66
  });
66
67
 
68
+ test("recognizes Windows named pipe paths", () => {
69
+ expect(isWindowsNamedPipePath("\\\\.\\pipe\\libretto-abc123")).toBe(true);
70
+ expect(isWindowsNamedPipePath("/tmp/libretto-501-abc123.sock")).toBe(false);
71
+ });
72
+
67
73
  test("rejects pending calls when the socket closes", async ({ socketPath }) => {
68
74
  const serverPeers: Array<IpcPeer<ClientApi>> = [];
69
75
  const server = await listenForIpcConnections(socketPath, (transport) => {
@@ -1,4 +1,4 @@
1
- import { rm } from "node:fs/promises";
1
+ import { mkdir, rm } from "node:fs/promises";
2
2
  import {
3
3
  createServer,
4
4
  createConnection,
@@ -6,7 +6,6 @@ import {
6
6
  type Socket,
7
7
  } from "node:net";
8
8
  import { dirname } from "node:path";
9
- import { mkdir } from "node:fs/promises";
10
9
  import type { IpcProtocolMessage, IpcTransport } from "./ipc.js";
11
10
 
12
11
  function createJsonSocketTransport(
@@ -109,13 +108,12 @@ export async function listenOnIpcSocket(
109
108
  server: Server,
110
109
  socketPath: string,
111
110
  ): Promise<void> {
112
- await mkdir(dirname(socketPath), { recursive: true });
113
- await rm(socketPath, { force: true });
111
+ await prepareIpcSocketPath(socketPath);
114
112
 
115
113
  const originalClose = server.close.bind(server);
116
114
  server.close = ((callback?: (error?: Error) => void) => {
117
115
  return originalClose((error?: Error) => {
118
- void rm(socketPath, { force: true }).finally(() => callback?.(error));
116
+ void removeStaleSocketFile(socketPath).finally(() => callback?.(error));
119
117
  });
120
118
  }) as Server["close"];
121
119
 
@@ -135,6 +133,23 @@ export async function listenOnIpcSocket(
135
133
  });
136
134
  }
137
135
 
136
+ export function isWindowsNamedPipePath(socketPath: string): boolean {
137
+ return socketPath.startsWith("\\\\.\\pipe\\");
138
+ }
139
+
140
+ async function prepareIpcSocketPath(socketPath: string): Promise<void> {
141
+ if (isWindowsNamedPipePath(socketPath)) return;
142
+
143
+ await mkdir(dirname(socketPath), { recursive: true });
144
+ await removeStaleSocketFile(socketPath);
145
+ }
146
+
147
+ async function removeStaleSocketFile(socketPath: string): Promise<void> {
148
+ if (isWindowsNamedPipePath(socketPath)) return;
149
+
150
+ await rm(socketPath, { force: true });
151
+ }
152
+
138
153
  async function connectSocket(socketPath: string): Promise<Socket> {
139
154
  const socket = createConnection(socketPath);
140
155